Skip to content

Feat: convert user to alumni #28

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Nov 12, 2024
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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,17 @@ The following commands are available to work with users in the Compiler domain:

```bash
$ compiler-admin user -h
usage: compiler-admin user [-h] {create,convert,delete,offboard,reset-password,restore,signout} ...
usage: compiler-admin user [-h] {alumni,create,convert,delete,offboard,reset,restore,signout} ...

positional arguments:
{create,convert,delete,offboard,reset-password,restore,signout}
{alumni,create,convert,delete,offboard,reset,restore,signout}
The user command to run.
alumni Convert a user account to a Compiler alumni.
create Create a new user in the Compiler domain.
convert Convert a user account to a new type.
delete Delete a user account.
offboard Offboard a user account.
reset-password Reset a user's password to a randomly generated string.
reset Reset a user's password to a randomly generated string.
restore Restore an email backup from a prior offboarding.
signout Signs a user out from all active sessions.

Expand Down Expand Up @@ -151,15 +152,17 @@ Additional options are passed through to GAM, see more about [GAM user create](h

```bash
$ compiler-admin user convert -h
usage: compiler-admin user convert [-h] username {contractor,partner,staff}
usage: compiler-admin user convert [-h] [--force] [--notify NOTIFY] username {alumni,contractor,partner,staff}

positional arguments:
username A Compiler user account name, sans domain.
{contractor,partner,staff}
{alumni,contractor,partner,staff}
Target account type for this conversion.

options:
-h, --help show this help message and exit
--force Don't ask for confirmation before conversion.
--notify NOTIFY An email address to send the alumni's new password.
```

### Offboarding a user
Expand Down
3 changes: 2 additions & 1 deletion compiler_admin/commands/user/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from argparse import Namespace

from compiler_admin.commands.user.alumni import alumni # noqa: F401
from compiler_admin.commands.user.create import create # noqa: F401
from compiler_admin.commands.user.convert import convert # noqa: F401
from compiler_admin.commands.user.delete import delete # noqa: F401
from compiler_admin.commands.user.offboard import offboard # noqa: F401
from compiler_admin.commands.user.reset_password import reset_password # noqa: F401
from compiler_admin.commands.user.reset import reset # noqa: F401
from compiler_admin.commands.user.restore import restore # noqa: F401
from compiler_admin.commands.user.signout import signout # noqa: F401

Expand Down
71 changes: 71 additions & 0 deletions compiler_admin/commands/user/alumni.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from argparse import Namespace

from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS
from compiler_admin.commands.user.reset import reset
from compiler_admin.services.google import (
OU_ALUMNI,
CallGAMCommand,
move_user_ou,
user_account_name,
user_exists,
)


def alumni(args: Namespace) -> int:
"""Convert a user to a Compiler alumni.

Optionally notify an email address with the new randomly generated password.

Args:
username (str): the user account to convert.

notify (str): an email address to send the new password notification.
Returns:
A value indicating if the operation succeeded or failed.
"""
if not hasattr(args, "username"):
raise ValueError("username is required")

account = user_account_name(args.username)

if not user_exists(account):
print(f"User does not exist: {account}")
return RESULT_FAILURE

if getattr(args, "force", False) is False:
cont = input(f"Convert account to alumni for {account}? (Y/n)")
if not cont.lower().startswith("y"):
print("Aborting conversion.")
return RESULT_SUCCESS

res = RESULT_SUCCESS

print("Removing from groups")
res += CallGAMCommand(("user", account, "delete", "groups"))

print(f"Moving to OU: {OU_ALUMNI}")
res += move_user_ou(account, OU_ALUMNI)

# reset password, sign out
res += reset(args)

print("Clearing user profile info")
for prop in ["address", "location", "otheremail", "phone"]:
command = ("update", "user", account, prop, "clear")
res += CallGAMCommand(command)

print("Resetting recovery email")
recovery = getattr(args, "recovery_email", "")
command = ("update", "user", account, "recoveryemail", recovery)
res += CallGAMCommand(command)

print("Resetting recovery phone")
recovery = getattr(args, "recovery_phone", "")
command = ("update", "user", account, "recoveryphone", recovery)
res += CallGAMCommand(command)

print("Turning off 2FA")
command = ("user", account, "turnoff2sv")
res += CallGAMCommand(command)

return res
9 changes: 7 additions & 2 deletions compiler_admin/commands/user/convert.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from argparse import Namespace

from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE
from compiler_admin.commands.user.alumni import alumni
from compiler_admin.services.google import (
GROUP_PARTNERS,
GROUP_STAFF,
OU_ALUMNI,
OU_CONTRACTORS,
OU_PARTNERS,
OU_STAFF,
Expand All @@ -17,7 +19,7 @@
)


ACCOUNT_TYPE_OU = {"contractor": OU_CONTRACTORS, "partner": OU_PARTNERS, "staff": OU_STAFF}
ACCOUNT_TYPE_OU = {"alumni": OU_ALUMNI, "contractor": OU_CONTRACTORS, "partner": OU_PARTNERS, "staff": OU_STAFF}


def convert(args: Namespace) -> int:
Expand Down Expand Up @@ -48,7 +50,10 @@ def convert(args: Namespace) -> int:
print(f"User exists, converting to: {account_type} for {account}")
res = RESULT_SUCCESS

if account_type == "contractor":
if account_type == "alumni":
res = alumni(args)

elif account_type == "contractor":
if user_is_partner(account):
res += remove_user_from_group(account, GROUP_PARTNERS)
res += remove_user_from_group(account, GROUP_STAFF)
Expand Down
7 changes: 2 additions & 5 deletions compiler_admin/commands/user/offboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
from tempfile import NamedTemporaryFile

from compiler_admin import RESULT_SUCCESS, RESULT_FAILURE
from compiler_admin.commands.user.alumni import alumni
from compiler_admin.commands.user.delete import delete
from compiler_admin.commands.user.signout import signout
from compiler_admin.services.google import (
USER_ARCHIVE,
CallGAMCommand,
Expand Down Expand Up @@ -48,8 +48,7 @@ def offboard(args: Namespace) -> int:
print(f"User exists, offboarding: {account}")
res = RESULT_SUCCESS

print("Removing from groups")
res += CallGAMCommand(("user", account, "delete", "groups"))
res += alumni(args)

print("Backing up email")
res += CallGYBCommand(("--service-account", "--email", account, "--action", "backup"))
Expand All @@ -67,8 +66,6 @@ def offboard(args: Namespace) -> int:

res += CallGAMCommand(("user", account, "deprovision", "popimap"))

res += signout(args)

res += delete(args)

if alias_account:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from compiler_admin.services.google import USER_HELLO, CallGAMCommand, user_account_name, user_exists


def reset_password(args: Namespace) -> int:
def reset(args: Namespace) -> int:
"""Reset a user's password.

Optionally notify an email address with the new randomly generated password.
Expand All @@ -26,6 +26,12 @@ def reset_password(args: Namespace) -> int:
print(f"User does not exist: {account}")
return RESULT_FAILURE

if getattr(args, "force", False) is False:
cont = input(f"Reset password for {account}? (Y/n)")
if not cont.lower().startswith("y"):
print("Aborting password reset.")
return RESULT_SUCCESS

command = ("update", "user", account, "password", "random", "changepassword")

notify = getattr(args, "notify", None)
Expand Down
13 changes: 12 additions & 1 deletion compiler_admin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,21 @@ def setup_user_command(cmd_parsers: _SubParsersAction):
user_cmd.set_defaults(func=user)
user_subcmds = add_sub_cmd_parser(user_cmd, help="The user command to run.")

user_alumni = add_sub_cmd_with_username_arg(user_subcmds, "alumni", help="Convert a user account to a Compiler alumni.")
user_alumni.add_argument("--notify", help="An email address to send the alumni's new password.")
user_alumni.add_argument(
"--force", action="store_true", default=False, help="Don't ask for confirmation before conversion."
)

user_create = add_sub_cmd_with_username_arg(user_subcmds, "create", help="Create a new user in the Compiler domain.")
user_create.add_argument("--notify", help="An email address to send the newly created account info.")

user_convert = add_sub_cmd_with_username_arg(user_subcmds, "convert", help="Convert a user account to a new type.")
user_convert.add_argument("account_type", choices=ACCOUNT_TYPE_OU.keys(), help="Target account type for this conversion.")
user_convert.add_argument(
"--force", action="store_true", default=False, help="Don't ask for confirmation before conversion."
)
user_convert.add_argument("--notify", help="An email address to send the alumni's new password.")

user_delete = add_sub_cmd_with_username_arg(user_subcmds, "delete", help="Delete a user account.")
user_delete.add_argument("--force", action="store_true", default=False, help="Don't ask for confirmation before deletion.")
Expand All @@ -149,8 +159,9 @@ def setup_user_command(cmd_parsers: _SubParsersAction):
)

user_reset = add_sub_cmd_with_username_arg(
user_subcmds, "reset-password", help="Reset a user's password to a randomly generated string."
user_subcmds, "reset", help="Reset a user's password to a randomly generated string."
)
user_reset.add_argument("--force", action="store_true", default=False, help="Don't ask for confirmation before reset.")
user_reset.add_argument("--notify", help="An email address to send the newly generated password.")

add_sub_cmd_with_username_arg(user_subcmds, "restore", help="Restore an email backup from a prior offboarding.")
Expand Down
3 changes: 2 additions & 1 deletion compiler_admin/services/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
DOMAIN = "compiler.la"

# Org structure
OU_ALUMNI = "alumni"
OU_CONTRACTORS = "contractors"
OU_STAFF = "staff"
OU_PARTNERS = f"{OU_STAFF}/partners"
Expand Down Expand Up @@ -77,7 +78,7 @@ def add_user_to_group(username: str, group: str) -> int:


def move_user_ou(username: str, ou: str) -> int:
"""Remove a user from a group."""
"""Move a user into a new OU."""
return CallGAMCommand(("update", "ou", ou, "move", username))


Expand Down
102 changes: 102 additions & 0 deletions tests/commands/user/test_alumni.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from argparse import Namespace
import pytest

from compiler_admin import RESULT_FAILURE, RESULT_SUCCESS
from compiler_admin.commands.user.alumni import alumni, __name__ as MODULE
from compiler_admin.services.google import OU_ALUMNI


@pytest.fixture
def mock_commands_reset(mock_commands_reset):
return mock_commands_reset(MODULE)


@pytest.fixture
def mock_input_yes(mock_input):
fix = mock_input(MODULE)
fix.return_value = "y"
return fix


@pytest.fixture
def mock_input_no(mock_input):
fix = mock_input(MODULE)
fix.return_value = "n"
return fix


@pytest.fixture
def mock_google_CallGAMCommand(mock_google_CallGAMCommand):
return mock_google_CallGAMCommand(MODULE)


@pytest.fixture
def mock_google_move_user_ou(mock_google_move_user_ou):
return mock_google_move_user_ou(MODULE)


@pytest.fixture
def mock_google_remove_user_from_group(mock_google_remove_user_from_group):
return mock_google_remove_user_from_group(MODULE)


@pytest.fixture
def mock_google_user_exists(mock_google_user_exists):
return mock_google_user_exists(MODULE)


def test_alumni_username_required():
args = Namespace()

with pytest.raises(ValueError, match="username is required"):
alumni(args)


def test_alumni_user_does_not_exists(mock_google_user_exists, mock_google_CallGAMCommand):
mock_google_user_exists.return_value = False

args = Namespace(username="username")
res = alumni(args)

assert res == RESULT_FAILURE
mock_google_CallGAMCommand.assert_not_called()


@pytest.mark.usefixtures("mock_input_yes")
def test_alumni_confirm_yes(
mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_reset, mock_google_move_user_ou
):
mock_google_user_exists.return_value = True

args = Namespace(username="username", force=False)
res = alumni(args)

assert res == RESULT_SUCCESS
mock_google_CallGAMCommand.assert_called()
mock_google_move_user_ou.assert_called_once_with("username@compiler.la", OU_ALUMNI)
mock_commands_reset.assert_called_once_with(args)


@pytest.mark.usefixtures("mock_input_no")
def test_alumni_confirm_no(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_reset, mock_google_move_user_ou):
mock_google_user_exists.return_value = True

args = Namespace(username="username", force=False)
res = alumni(args)

assert res == RESULT_SUCCESS
mock_google_CallGAMCommand.assert_not_called()
mock_commands_reset.assert_not_called()
mock_google_move_user_ou.assert_not_called()


def test_alumni_force(mock_google_user_exists, mock_google_CallGAMCommand, mock_commands_reset, mock_google_move_user_ou):
mock_google_user_exists.return_value = True

args = Namespace(username="username", force=True)
res = alumni(args)

assert res == RESULT_SUCCESS
mock_google_CallGAMCommand.assert_called()
mock_google_move_user_ou.assert_called_once_with("username@compiler.la", OU_ALUMNI)
mock_commands_reset.assert_called_once_with(args)
15 changes: 15 additions & 0 deletions tests/commands/user/test_convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
from compiler_admin.commands.user.convert import convert, __name__ as MODULE


@pytest.fixture
def mock_commands_alumni(mock_commands_alumni):
return mock_commands_alumni(MODULE)


@pytest.fixture
def mock_google_user_exists(mock_google_user_exists):
return mock_google_user_exists(MODULE)
Expand Down Expand Up @@ -98,6 +103,16 @@ def test_convert_user_exists_bad_account_type(mock_google_move_user_ou):
assert mock_google_move_user_ou.call_count == 0


@pytest.mark.usefixtures("mock_google_user_exists_true")
def test_convert_alumni(mock_commands_alumni, mock_google_move_user_ou):
args = Namespace(username="username", account_type="alumni")
res = convert(args)

assert res == RESULT_SUCCESS
mock_commands_alumni.assert_called_once_with(args)
mock_google_move_user_ou.assert_called_once()


@pytest.mark.usefixtures(
"mock_google_user_exists_true", "mock_google_user_is_partner_false", "mock_google_user_is_staff_false"
)
Expand Down
Loading
Loading