bekkalokk/gitea/import-users: refactor + add members to groups #70
|
@ -14,6 +14,9 @@ in
|
|||
preStart=''${pkgs.rsync}/bin/rsync -e "${pkgs.openssh}/bin/ssh -o UserKnownHostsFile=$CREDENTIALS_DIRECTORY/ssh-known-hosts -i $CREDENTIALS_DIRECTORY/sshkey" -a pvv@smtp.pvv.ntnu.no:/etc/passwd /tmp/passwd-import'';
|
||||
serviceConfig = {
|
||||
ExecStart = pkgs.writers.writePython3 "gitea-import-users" {
|
||||
flakeIgnore = [
|
||||
"E501" # Line over 80 chars lol
|
||||
];
|
||||
libraries = with pkgs.python3Packages; [ requests ];
|
||||
} (builtins.readFile ./gitea-import-users.py);
|
||||
LoadCredential=[
|
||||
|
|
|
@ -2,18 +2,92 @@ import requests
|
|||
import secrets
|
||||
import os
|
||||
|
||||
|
||||
EMAIL_DOMAIN = os.getenv('EMAIL_DOMAIN')
|
||||
if EMAIL_DOMAIN is None:
|
||||
EMAIL_DOMAIN = 'pvv.ntnu.no'
|
||||
|
||||
|
||||
API_TOKEN = os.getenv('API_TOKEN')
|
||||
if API_TOKEN is None:
|
||||
raise Exception('API_TOKEN not set')
|
||||
|
||||
|
||||
GITEA_API_URL = os.getenv('GITEA_API_URL')
|
||||
if GITEA_API_URL is None:
|
||||
GITEA_API_URL = 'https://git.pvv.ntnu.no/api/v1'
|
||||
|
||||
|
||||
def gitea_list_all_users() -> dict[str, dict[str, any]] | None:
|
||||
r = requests.get(
|
||||
GITEA_API_URL + '/admin/users',
|
||||
headers={'Authorization': 'token ' + API_TOKEN}
|
||||
)
|
||||
|
||||
if r.status_code != 200:
|
||||
print('Failed to get users:', r.text)
|
||||
return None
|
||||
|
||||
return {user['login']: user for user in r.json()}
|
||||
|
||||
|
||||
def gitea_create_user(username: str, userdata: dict[str, any]) -> bool:
|
||||
r = requests.post(
|
||||
GITEA_API_URL + '/admin/users',
|
||||
json=userdata,
|
||||
headers={'Authorization': 'token ' + API_TOKEN},
|
||||
)
|
||||
|
||||
if r.status_code != 201:
|
||||
print(f'ERR: Failed to create user {username}:', r.text)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def gitea_edit_user(username: str, userdata: dict[str, any]) -> bool:
|
||||
r = requests.patch(
|
||||
GITEA_API_URL + f'/admin/users/{username}',
|
||||
json=userdata,
|
||||
headers={'Authorization': 'token ' + API_TOKEN},
|
||||
)
|
||||
|
||||
if r.status_code != 200:
|
||||
print(f'ERR: Failed to update user {username}:', r.text)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def gitea_list_teams_for_organization(org: str) -> dict[str, any] | None:
|
||||
r = requests.get(
|
||||
GITEA_API_URL + f'/orgs/{org}/teams',
|
||||
headers={'Authorization': 'token ' + API_TOKEN},
|
||||
)
|
||||
|
||||
if r.status_code != 200:
|
||||
print(f"ERR: Failed to list teams for {org}:", r.text)
|
||||
return None
|
||||
|
||||
return {team['name']: team for team in r.json()}
|
||||
|
||||
|
||||
def gitea_add_user_to_organization_team(username: str, team_id: int) -> bool:
|
||||
r = requests.put(
|
||||
GITEA_API_URL + f'/teams/{team_id}/members/{username}',
|
||||
headers={'Authorization': 'token ' + API_TOKEN},
|
||||
)
|
||||
|
||||
if r.status_code != 204:
|
||||
print(f'ERR: Failed to add user {username} to org team {team_id}:', r.text)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# If a passwd user has one of the following shells,
|
||||
# it is most likely not a PVV user, but rather a system user.
|
||||
# Users with these shells should thus be ignored.
|
||||
BANNED_SHELLS = [
|
||||
"/usr/bin/nologin",
|
||||
"/usr/sbin/nologin",
|
||||
|
@ -22,59 +96,11 @@ BANNED_SHELLS = [
|
|||
"/bin/msgsh",
|
||||
]
|
||||
|
||||
oysteikt marked this conversation as resolved
Outdated
|
||||
existing_users = {}
|
||||
|
||||
|
||||
# This function should only ever be called when adding users
|
||||
# from the passwd file
|
||||
def add_user(username, name):
|
||||
user = {
|
||||
"full_name": name,
|
||||
"username": username,
|
||||
"login_name": username,
|
||||
"source_id": 1, # 1 = SMTP
|
||||
}
|
||||
|
||||
if username not in existing_users:
|
||||
user["password"] = secrets.token_urlsafe(32)
|
||||
user["must_change_password"] = False
|
||||
user["visibility"] = "private"
|
||||
user["email"] = username + '@' + EMAIL_DOMAIN
|
||||
|
||||
r = requests.post(GITEA_API_URL + '/admin/users', json=user,
|
||||
headers={'Authorization': 'token ' + API_TOKEN})
|
||||
if r.status_code != 201:
|
||||
print('ERR: Failed to create user ' + username + ': ' + r.text)
|
||||
return
|
||||
|
||||
print('Created user ' + username)
|
||||
existing_users[username] = user
|
||||
|
||||
else:
|
||||
user["visibility"] = existing_users[username]["visibility"]
|
||||
r = requests.patch(GITEA_API_URL + f'/admin/users/{username}',
|
||||
json=user,
|
||||
headers={'Authorization': 'token ' + API_TOKEN})
|
||||
if r.status_code != 200:
|
||||
print('ERR: Failed to update user ' + username + ': ' + r.text)
|
||||
return
|
||||
|
||||
print('Updated user ' + username)
|
||||
|
||||
|
||||
def main():
|
||||
# Fetch existing users
|
||||
r = requests.get(GITEA_API_URL + '/admin/users',
|
||||
headers={'Authorization': 'token ' + API_TOKEN})
|
||||
|
||||
if r.status_code != 200:
|
||||
raise Exception('Failed to get users: ' + r.text)
|
||||
|
||||
for user in r.json():
|
||||
existing_users[user['login']] = user
|
||||
|
||||
# Read the file, add each user
|
||||
with open("/tmp/passwd-import", 'r') as f:
|
||||
# Reads out a passwd-file line for line, and filters out
|
||||
# real PVV users (as opposed to system users meant for daemons and such)
|
||||
def passwd_file_parser(passwd_path):
|
||||
with open(passwd_path, 'r') as f:
|
||||
for line in f.readlines():
|
||||
uid = int(line.split(':')[2])
|
||||
if uid < 1000:
|
||||
|
@ -86,8 +112,86 @@ def main():
|
|||
|
||||
username = line.split(':')[0]
|
||||
name = line.split(':')[4].split(',')[0]
|
||||
yield (username, name)
|
||||
|
||||
add_user(username, name)
|
||||
|
||||
# This function either creates a new user in gitea
|
||||
# and fills it out with some default information if
|
||||
# it does not exist, or ensures that the default information
|
||||
# is correct if the user already exists. All user information
|
||||
# (including non-default fields) is pulled from gitea and added
|
||||
# to the `existing_users` dict
|
||||
def add_or_patch_gitea_user(
|
||||
username: str,
|
||||
name: str,
|
||||
existing_users: dict[str, dict[str, any]],
|
||||
) -> None:
|
||||
user = {
|
||||
"full_name": name,
|
||||
"username": username,
|
||||
"login_name": username,
|
||||
"source_id": 1, # 1 = SMTP
|
||||
}
|
||||
|
||||
if username not in existing_users:
|
||||
oysteikt marked this conversation as resolved
Outdated
felixalb
commented
This "patch existing users" section should probably be it's own function, and be explained to show what it is / should be doing. This "patch existing users" section should probably be it's own function, and be explained to show what it is / should be doing.
oysteikt
commented
It' not immediately clear to me at the moment how to best split this, when both depend on the same base user settings with name and username as input, and not just creating a bunch of overhead. I'll add a comment describing what it does though. Feel free to fix it up and ping me in another PR. It' not immediately clear to me at the moment how to best split this, when both depend on the same base user settings with name and username as input, and not just creating a bunch of overhead. I'll add a comment describing what it does though. Feel free to fix it up and ping me in another PR.
|
||||
user["password"] = secrets.token_urlsafe(32)
|
||||
user["must_change_password"] = False
|
||||
user["visibility"] = "private"
|
||||
user["email"] = username + '@' + EMAIL_DOMAIN
|
||||
|
||||
if not gitea_create_user(username, user):
|
||||
return
|
||||
|
||||
print('Created user', username)
|
||||
existing_users[username] = user
|
||||
|
||||
else:
|
||||
user["visibility"] = existing_users[username]["visibility"]
|
||||
|
||||
if not gitea_edit_user(username, user):
|
||||
return
|
||||
|
||||
print('Updated user', username)
|
||||
|
||||
|
||||
# This function adds a user to a gitea team (part of organization)
|
||||
# if the user is not already part of said team.
|
||||
def ensure_gitea_user_is_part_of_team(
|
||||
username: str,
|
||||
org: str,
|
||||
team_name: str,
|
||||
) -> None:
|
||||
teams = gitea_list_teams_for_organization(org)
|
||||
|
||||
if teams is None:
|
||||
return
|
||||
|
||||
if team_name not in teams:
|
||||
print(f'ERR: could not find team "{team_name}" in organization "{org}"')
|
||||
|
||||
oysteikt marked this conversation as resolved
Outdated
felixalb
commented
The splitting into "add" or "patch" should probably also be done here, rather than in one large function, as they are quite different. The splitting into "add" or "patch" should probably also be done here, rather than in one large function, as they are quite different.
|
||||
gitea_add_user_to_organization_team(username, teams[team_name]['id'])
|
||||
oysteikt marked this conversation as resolved
Outdated
felixalb
commented
This makes it explicitly impossible to leave either of those teams for good, is that intended? I suggest moving this into the "add new user" function, and not the "update existing user" section. This makes it explicitly impossible to leave either of those teams for good, is that intended? I suggest moving this into the "add new user" function, and not the "update existing user" section.
oysteikt
commented
This is intended. All PVV members should be able to edit PVV projects. There are no downsides to being a member of the organization (at least when we fix the autowatch thing). We could alternatively provide the projects as an input to the script instead of hardcoding a list so it's more obvious from the nix code, but this will work for now. Maybe when there's more orgs to join This is intended. All PVV members should be able to edit PVV projects. There are no downsides to being a member of the organization (at least when we fix the autowatch thing). We could alternatively provide the projects as an input to the script instead of hardcoding a list so it's more obvious from the nix code, but this will work for now. Maybe when there's more orgs to join
|
||||
|
||||
print(f'User {username} is now part of {org}/{team_name}')
|
||||
|
||||
|
||||
# List of teams that all users should be part of by default
|
||||
COMMON_USER_TEAMS = [
|
||||
("Projects", "Members"),
|
||||
("Kurs", "Members"),
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
existing_users = gitea_list_all_users()
|
||||
if existing_users is None:
|
||||
exit(1)
|
||||
|
||||
for username, name in passwd_file_parser("/tmp/passwd-import"):
|
||||
print(f"Processing {username}")
|
||||
add_or_patch_gitea_user(username, name, existing_users)
|
||||
for org, team_name in COMMON_USER_TEAMS:
|
||||
ensure_gitea_user_is_part_of_team(username, org, team_name)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
@ -6,7 +6,7 @@ gitea:
|
|||
email-password: ENC[AES256_GCM,data:KRwC+aL1aPvJuXt91Oq1ttATMnFTnuUy,iv:ats8TygB/2pORkaTZzPOLufZ9UmvVAKoRcWNvYF1z6w=,tag:Do0fA+4cZ3+l7JJyu8hjBg==,type:str]
|
||||
passwd-ssh-key: ENC[AES256_GCM,data:L0lF0wvpayss1NU9m3A45cH0bCMQzODTFVrq6EPd1JHx54wIcoaRBYLmxXKXASzBlCg9zlwXMUIk3OQcS3kdzMKL0iqcSL2iicAcKjFIHyrWLqXgwV5pRSP/tRPcVw8KW8gz0bh33EgESs5ReddZ3VZ0Cy1s2YupMRQvBXr89k1+Hv70OWB6P06hvxhv/zKcMGI1N/dWLroMgrQuT9imw4+/Q1RqwzTYeEU+eUn24AM9GjcBg4qf3OI+6g0nXUat/upIYE28iF5J3lbUSmDSmirBLc8xgHLdOyyJPTObWYWYxlSL78T7IqiMm9lI3rtBlpJDDcn/YxZpVqN5bg2154GISNK+uR0TVSLdJ+drdGHIfIX3G78XSxf2L9rbJyRn8MQlgStfdBIQicLavQKVMrmj+XQfvEMez23WbPLjH4oViBQFI+GrOHOGy/f16cz8Sn4n+69OcsOeTxs3tKYdfq6r1XLYSJ/fe/zvxBpaZiyGXljsuyEdIyBL2A8D6uSXe3Nd3/DAdBtceFfIdN1olCdutixzVWgxaJnrel161z5A/4w=,iv:Uy46yY3jFYSvpxrgCHxRMUksnWfhf5DViLMvCXVMMl4=,tag:wFEJ5+icFrOKkc56gY0A5g==,type:str]
|
||||
ssh-known-hosts: ENC[AES256_GCM,data:zlRLoelQeumMxGqPmgMTB69X1RVWXIs2jWwc67lk0wrdNOHUs5UzV5TUA1JnQ43RslBU92+js7DkyvE5enGzw7zZE5F1ZYdGv/eCgvkTMC9BoLfzHzP6OzayPLYEt3xJ5PRocN8JUAD55cuu4LgsuebuydHPi2oWOfpbSUBKSeCh6dvk5Pp1XRDprPS5SzGLW8Xjq98QlzmfGv50meI9CDJZVF9Wq/72gkyfgtb3YVdr,iv:AF06TBitHegfWk6w07CdkHklh4ripQCmA45vswDQgss=,tag:zKh7WVXMJN2o9ZIwIkby3Q==,type:str]
|
||||
import-user-env: ENC[AES256_GCM,data:vfaqjGEnUM9VtOPvBurz7nFwzGZt3L2EqijrQej4wiOcGCrRA4tN6kBV6NmhHqlFPsw=,iv:viPGkyOOacCWcgTu25da4qH7DC4wz2qdeC1W2WcMUdI=,tag:BllNqGQoaxqUo3lTz9LGnw==,type:str]
|
||||
import-user-env: ENC[AES256_GCM,data:wArFwTd0ZoB4VXHPpichfnmykxGxN8y2EQsMgOPHv7zsm6A+m2rG9BWDGskQPr5Ns9o=,iv:gPUzYFSNoALJb1N0dsbNlgHIb7+xG7E9ANpmVNZURQ0=,tag:JghfRy2OcDFWKS9zX1XJ9A==,type:str]
|
||||
runners:
|
||||
alpha: ENC[AES256_GCM,data:gARxCufePz+EMVwEwRsL2iZUfh9HUowWqtb7Juz3fImeeAdbt+k3DvL/Nwgegg==,iv:3fEaWd7v7uLGTy2J7EFQGfN0ztI0uCOJRz5Mw8V5UOU=,tag:Aa6LwWeW2hfDz1SqEhUJpA==,type:str]
|
||||
beta: ENC[AES256_GCM,data:DVjS78IKWiWgf+PuijCZKx4ZaEJGhQr7vl+lc7QOg1JlA4p9Kux/tOD8+f2+jA==,iv:tk3Xk7lKWNdZ035+QVIhxXy2iJbHwunI4jRFM4It46E=,tag:9Mr6o//svYEyYhSvzkOXMg==,type:str]
|
||||
|
@ -92,8 +92,8 @@ sops:
|
|||
UHpLRkdQTnhkeGlWVG9VS1hkWktyckEKAdwnA9URLYZ50lMtXrU9Q09d0L3Zfsyr
|
||||
4UsvjjdnFtsXwEZ9ZzOQrpiN0Oz24s3csw5KckDni6kslaloJZsLGg==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2024-08-13T19:49:24Z"
|
||||
mac: ENC[AES256_GCM,data:AeJ53D+8A8mHYRmVHdqhcS1ZTbqVe5gQqJsJjMk4T/ZlNX8/V4M9mqAW2FB9m/JSdj234gDu+PBHcW70ZrCqeVsoUW/ETVgUX3W2gBmBgYJiRETp8I7/eks/5YEV6vIIxQsZNP/9dZTNX4T2wD74ELl23NSTXA/6k2tyzBlTMYo=,iv:DABafHvw+5w0PHCKqLgpwmQnv0uHOTyj+s8gdnHFTZ4=,tag:SNZ7W+6zdyuuv2AB9ir8eg==,type:str]
|
||||
lastmodified: "2024-08-26T19:38:58Z"
|
||||
mac: ENC[AES256_GCM,data:3FyfZPmJ7znQEul+IwqN1ZaM53n6os3grquJwJ9vfyDSc2h8UZBhqYG+2uW9Znp9DSIjuhCUI8iqGKRJE0M/6IDICeXms/5+ynVFOS9bA2cdzPvWaj0FFAd2x3g4Vhs47+vRlsnIe/tMiKU3IOvzOfI6KAUHc9L2ySrzH7z2+fo=,iv:1iZSR9qOIEtf+fNbtWSwJBIUEQGKadfHSVOnkFzOwq8=,tag:Sk6JEU1B6Rd1GXLYC6rQtQ==,type:str]
|
||||
pgp:
|
||||
- created_at: "2024-08-04T00:03:28Z"
|
||||
enc: |-
|
||||
|
|
Here, you are both explicitly allowing very long lines, and strictly wrapping the text. Maybe collapse this type of comment into single lines?
Eh, I'm not intentionally creating long lines, just not breaking the non-important ones (e.g. 100 char strings).