def __register_computer()

in ad-joining/register-computer/main.py [0:0]


def __register_computer(request):
    """
        Create a computer account for the joining computer.
    """
   
    # Authenticate HTTP request
    auth_info = __authenticate_request(request)

    # Connect to Active Directory so that we can authorize the request.
    try:
        ad_site = request.args.get("ad_site")
        ad_connection = __connect_to_activedirectory(ad_site)
    except Exception as e:
        logging.exception("Connecting to Active Directory failed")
        return flask.abort(HTTP_BAD_GATEWAY, description="CONNECT_TO_AD_FAILED")

    # Authorize the request. This entails two checks:
    # (1) Check that the project allows AD to manage computer accounts for it.
    #     This is to prevent users from joining machines without the project
    #     owner authorizing it.
    # (2) Check that AD allows the project to join machines. This is to prevent
    #     rogue/unauthorized projects from joining machines.

    # Authorize, Part 1: Check that we have read access to the project's VM.
    # Read access implies that a project owner or security admin of the project
    # is OK with us managing computer accounts for the project's machines.
    #
    # Access is also required to scavenge stale computer accounts - checking it now
    # reduces the risk of us not being able to scavenge later because of lacking
    # permissions.
    try:
        gce_instance = gcp.project.Project(auth_info.get_project_id()).get_instance(
            auth_info.get_instance_name(), 
            auth_info.get_zone())
        if gce_instance is None:
            raise Exception("Instance does not exist")

        # Read the hostname, it might be different from the instance name.
        if "hostname" in gce_instance:
            computer_name = gce_instance["hostname"]
            if "." in computer_name:
                computer_name = computer_name.split(".")[0] # Strip domain
        else:
            computer_name = auth_info.get_instance_name()

        logging.info("Successfully read GCE instance data for '%s' (hostname: '%s'), authorized (1/2)" %
            (auth_info.get_instance_name(), computer_name))
    except Exception as e:
        logging.exception("Checking project access to '%s' failed" % auth_info.get_project_id())
        return flask.abort(HTTP_ACCESS_DENIED, description="PROJECT_ACCESS_FAILED")

    # Authorize, Part 2: Check that there is a OU with the same name as the
    # project id. The existence of such a OU implies that the owner of the
    # domain is OK with joining machines from that project.
    # This check is ignored if the service is configured to use a custom OU.
    # For custom OU, users will need to use an external way to make sure rogue/unauthorized
    # projects cannot access the service, for example, by using a Shared VPC with Service projects.
    computer_ou = None
    if "PROJECTS_DN" in os.environ and "CUSTOM_OU_ROOT_DN" in os.environ:
        logging.error("Cannot have both PROJECTS_DN and CUSTOM_OU_ROOT_DN environment variables configured. Please make sure only one is present.")
        return flask.abort(HTTP_CONFLICT, description="BAD_ROOT_OU_CONFIGURATION")
    elif "PROJECTS_DN" in os.environ:
        try:
            matches = ad_connection.find_ou(__read_required_setting("PROJECTS_DN"), auth_info.get_project_id())
            if len(matches) == 0:
                logging.error("No OU with name '%s' found in directory" % auth_info.get_project_id())
                return flask.abort(HTTP_ACCESS_DENIED, description="MISSING_PROJECT_OU")
            elif len(matches) > 1:
                logging.error("Found multiple OUs with name '%s' in directory" % auth_info.get_project_id())
                return flask.abort(HTTP_ACCESS_DENIED, description="MULTIPLE_PROJECT_OUS")
            else:
                # There is an OU. That means the request is fine and we also know which
                # OU to create a computer account in.
                project_ou = matches[0].get_dn()                
                logging.info("Found OU '%s', authorized (2/2)" % project_ou)
                logging.info("Computer will be created in a project OU: '%s'" % project_ou)
                computer_ou = project_ou
        except Exception as e:
            logging.exception("Looking up OU '%s' in Active Directory failed" % auth_info.get_project_id())
            return flask.abort(HTTP_INTERNAL_SERVER_ERROR, description="PROJECT_OU_UNKNOWN_ERROR")
    # If custom OU is being used, then extract it from the compute instance
    elif "CUSTOM_OU_ROOT_DN" in os.environ:
        try:
            custom_ou = __get_custom_ou_for_computer(ad_connection, gce_instance, auth_info.get_instance_name(), auth_info.get_project_id())
            if not custom_ou:
                return flask.abort(HTTP_BAD_REQUEST, description="BAD_CUSTOM_OU")

            logging.info("Found the OU '%s' that is a descendant of the custom root OU, authorized (2/2)" % custom_ou)
            logging.info("Computer will be created in a custom OU: '%s'" % custom_ou)
            computer_ou = custom_ou
        except Exception:
            return flask.abort(HTTP_INTERNAL_SERVER_ERROR, description="UNKNOWN_CUSTOM_OU_ERROR")
    else:
        logging.error("Could not find PROJECTS_DN nor CUSTOM_OU_ROOT_DN in the environment variables. Failed to configure OU root.")
        return flask.abort(HTTP_INTERNAL_SERVER_ERROR, description="BAD_ROOT_OU_CONFIGURATION")

    original_computer_name = computer_name

    if len(computer_name) > MAX_NETBIOS_COMPUTER_NAME_LENGTH:
        # Try to shorten the computer name
        computer_name = __shorten_computer_name(computer_name, gce_instance)
        logging.info("Computer name was shortened from %s to %s" % (original_computer_name, computer_name))

    # The request is now properly authorized, so we are all set to create
    # a computer account in the domain.
    domain = __read_required_setting("AD_DOMAIN")
    
    try:
        computer_upn = "%s$@%s" % (computer_name, domain)

        # Create computer and add metadata to trace its connection to the
        # GCE VM instance. We also add a temporary UPN to the computer
        # so that we can reset its password via Kerberos.
        try:
            computer_account_dn = ad_connection.add_computer(
                computer_ou,
                computer_name,
                computer_upn,
                auth_info.get_project_id(),
                auth_info.get_zone(),
                auth_info.get_instance_name())
            
            new_computer_account = True

        except ad.domain.AlreadyExistsException:
            # Computer already exists. If this is the same instance and project name
            # then assume this is a re-imaged VM, and continue
            computer_account_dn = ("CN=%s,%s" % (computer_name, computer_ou))
            try:
                computer_accounts = ad_connection.find_computer(computer_account_dn)

                computer_account = computer_accounts[0]
                # Validate this is the same project and instance name
                is_same_computer = (computer_account.get_instance_name() == auth_info.get_instance_name() 
                    and computer_account.get_project_id() == auth_info.get_project_id())

                if is_same_computer:
                    # Account found in AD is in the same project and has 
                    # the same name as the given instance so we can reuse it
                    logging.info("Account '%s' already exists, reusing" % computer_name)
                    # We need to add a temporary UPN to the computer
                    # so that we can reset its password via Kerberos
                    ad_connection.set_computer_upn(computer_ou, computer_name, computer_upn)

                    if (computer_account.get_zone() != auth_info.get_zone()):
                        # The instance we have was created in a different zone
                        # than then AD account. We need to update the zone attribute.
                        logging.info("Account '%s' is listed in a different zone (%s). Updating to zone %s" 
                            % (computer_name, computer_account.get_zone(), auth_info.get_zone()))
                        ad_connection.set_computer_zone(computer_ou, computer_name, auth_info.get_zone())
                    
                    new_computer_account = False
                else:
                    logging.error("Account '%s' already exists in OU '%s' with different attributes. Current attributes are (instance='%s', project='%s'), and requested attributes are (instance='%s', project='%s')" 
                        % (computer_name, computer_ou, computer_account.get_instance_name(), computer_account.get_project_id(), auth_info.get_instance_name(), auth_info.get_project_id()))
                    flask.abort(HTTP_CONFLICT, description="SIMILAR_COMPUTER_ACCOUNT_EXISTS_IN_AD")
            except ad.domain.NoSuchObjectException as e:
                logging.error("Account '%s' from project '%s' already exists, but cannot be found in OU '%s'. It probably belongs to a different project or is configured to use a different OU" % 
                    (computer_name, auth_info.get_project_id(), computer_ou))
                flask.abort(HTTP_CONFLICT, description="SIMILAR_COMPUTER_ACCOUNT_EXISTS_IN_AD")

        # Check if the instance is part of a Managed Instance Group (MIG)
        mig_info = __get_managed_instance_group_for_instance(gce_instance)
        mig_name = mig_info.get("name") if mig_info else None

        # New instances of MIGs are added to AD groups named after the MIGs.
        # Having an AD group with the MIG's computers is useful for managing
        # Access control in the domain
        if new_computer_account and mig_name:
            # Add the computer to an AD group containing all the MIG's computers
            # This is only relevant to newly added computers
            # as previously added computers were already added to the group
            logging.info("Instance '%s' is part of Managed Instance Group '%s'. Account will be added to a matching group" 
                % (auth_info.get_instance_name(), mig_name))

            # Find if the MIG already has an AD group
            mig_dn = ("CN=%s,%s" % (mig_name, computer_ou))
            try:
                mig_ad_group = ad_connection.find_group(mig_dn)

                logging.info("AD Group '%s' found. Adding computer '%s' to the group"
                    %(mig_name, auth_info.get_instance_name()))
            except ad.domain.NoSuchObjectException as e:
                # Group does not exists, create it.
                try:
                    logging.info("AD Group '%s' not found. Attempting to create it" % (mig_name))
                    ad_connection.add_group(computer_ou, mig_name, auth_info.get_project_id(), mig_info["zone"], mig_info["region"])
                except ad.domain.AlreadyExistsException:
                    # Two options why group already exists:
                    # 1. Group was just created by a parallel process adding another computer from the same MIG
                    # 2. Group by this name already exists in a different project
                    mig_ad_group = ad_connection.find_group(mig_dn)
                    if len(mig_ad_group) == 0:
                        # This error should raise a flag, as AD creates each group with a unique SAM account name, therefore
                        # we shouldn't get groups with the same ID in other OUs.
                        logging.error("Failed adding AD Group for MIG '%s' in project '%s'. There is probably a MIG by this name in another OU" 
                            % (mig_name, auth_info.get_project_id()))
                        flask.abort(HTTP_CONFLICT, "GROUP_ALREADY_EXISTS_IN_AD")
                    else: 
                        # Group added in the same project, safe to proceed
                        logging.info("AD Group '%s' found while creating. Assuming it was added by another computer joining in parallel" % (mig_name))                

            # Add the computer account to group
            ad_connection.add_member_to_group(computer_ou, mig_name, computer_account_dn)

        # Assign a random password via Kerberos. Using Kerberos instead of
        # LDAP avoids having to use Secure LDAP.
        kerberos_client = kerberos.password.KerberosPasswordClient(
            domain,
            ad_connection.get_domain_controller(),
            ad_connection.get_domain_controller(),
            ad_connection.get_upn_by_samaccountname(ad_connection.get_user()),
            __read_ad_password())

        set_password_attempt = 0
        while True:
            set_password_attempt += 1
            try:
                computer_password = __generate_password()
                kerberos_client.set_password(
                    computer_upn,
                    computer_password)
                break

            except kerberos.password.KerberosException as e:
                if e.get_error_code() == 1:
                    # Error is related to the agent (AD user). No point trying again
                    logging.error("Setting password for '%s' failed. Unrecoverable error" % (computer_upn))
                    raise e 
                if set_password_attempt <= PASSWORD_RESET_RETRIES:
                    # Setting the password might fail, so try again using a new password.
                    # Password setting is sent by AD to all DCs. Failure can occur in AD 
                    # with multiple DCs, if replication did not yet happen, and some DCs 
                    # are not aware to the new computer account.                    
                    logging.warning("Setting password for '%s' failed (attempt #%d), retrying with different password" % 
                        (computer_upn, set_password_attempt))
                    time.sleep(2)
                else:
                    # Give up
                    raise e

        # Remove the temporary UPN.
        ad_connection.remove_computer_upn(computer_ou, computer_name)

        logging.info("Created computer account '%s'" % (computer_account_dn))
    except werkzeug.exceptions.Conflict as e:
        # Re-throw HTTP 409 (Conflict) that is used throughout this try/except, 
        # to avoid it being replaced by the HTTP 500 for general exceptions 
        raise e
    except Exception as e:
        logging.exception("Creating computer account for '%s' in '%s' failed" %
            (computer_name, computer_ou))
        return flask.abort(HTTP_INTERNAL_SERVER_ERROR, description="UNKNOWN_ERROR_CREATE_COMPUTER_ACCOUNT")

    # Return credentials so that the computer can use them to join.
    return flask.jsonify(
        OriginalComputerName=original_computer_name,
        ComputerName=computer_name,
        ComputerPassword=computer_password,
        OrgUnitPath=computer_ou,
        Domain=domain,
        DomainController=ad_connection.get_domain_controller())