Source code for infrahouse_toolkit.cli.ih_openvpn.cmd_sync_google_users

"""
.. topic:: ``ih-openvpn sync-google-users``

    A ``ih-openvpn sync-google-users`` subcommand.

    See ``ih-openvpn sync-google-users --help`` for more details.
"""

import logging
import os
import sys
from subprocess import CalledProcessError
from typing import Iterable, Set, Tuple

import click

from infrahouse_toolkit.cli.ih_openvpn.exceptions import (
    EmptyDirectory,
    GoogleNotConfigured,
)
from infrahouse_toolkit.cli.ih_openvpn.lib import (
    index_path,
    revoke_client,
    valid_user_certificates,
)

LOG = logging.getLogger()

#: ``EX_CONFIG`` from sysexits.h. Signals "the Google side is not configured
#: yet" as distinct from "this is broken". The Puppet wrapper that drives this
#: command on a cron treats 78 as *not ready* -- it logs and exits 0 -- so a
#: fleet that has not yet had domain-wide delegation authorized stays quiet
#: instead of mailing an operator daily. Any other non-zero status is reported
#: as a genuine failure. Changing this value breaks that contract; see
#: puppet-code ``modules/profile/templates/openvpn_server/google-user-sync.sh.erb``.
EX_CONFIG = 78

DIRECTORY_SCOPE = "https://www.googleapis.com/auth/admin.directory.user.readonly"
CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"


# noinspection PyPackageRequirements,PyUnresolvedReferences
def _directory_client(service_account, admin_subject):
    """
    Build a Directory API client authenticated keylessly for ``admin_subject``.

    Application Default Credentials resolve to the Workload Identity Federation
    config that Terraform writes, which sources an AWS instance identity from
    IMDS. That federated identity impersonates the directory-reader service
    account, which in turn uses domain-wide delegation to act as
    ``admin_subject``. No service-account key exists anywhere.

    :param service_account: Email of the directory-reader service account.
    :type service_account: str
    :param admin_subject: Workspace admin the service account impersonates.
    :type admin_subject: str
    :return: A ready ``admin`` / ``directory_v1`` client.
    :raise GoogleNotConfigured: if credentials cannot be resolved yet.
    """
    # Imported here rather than at module top so the other ih-openvpn
    # subcommands (list-clients, revoke-client) do not pay to load the google
    # stack they never use. The libraries are declared dependencies, so a failed
    # import is a broken install and is left to propagate loudly -- not caught
    # and disguised as "not configured yet". The disable covers the whole block
    # because black rewrites trailing per-line comments into the parentheses,
    # where pylint no longer honours them.
    # pylint: disable=import-outside-toplevel
    import google.auth
    from google.auth import impersonated_credentials
    from google.auth.exceptions import DefaultCredentialsError
    from googleapiclient.discovery import build

    try:
        source_credentials, _ = google.auth.default(scopes=[CLOUD_PLATFORM_SCOPE])
    except DefaultCredentialsError as err:
        # No usable Application Default Credentials: the Terraform-written
        # credential config is missing or malformed, or IMDS is unreachable so
        # the external_account source cannot produce an AWS identity. All are
        # states that precede a finished deployment, not regressions.
        raise GoogleNotConfigured(f"no usable application default credentials: {err}") from err

    delegated = impersonated_credentials.Credentials(
        source_credentials=source_credentials,
        target_principal=service_account,
        target_scopes=[DIRECTORY_SCOPE],
        subject=admin_subject,
    )
    return build("admin", "directory_v1", credentials=delegated, cache_discovery=False)


# noinspection PyPackageRequirements,PyUnresolvedReferences
[docs]def get_active_directory_users(service_account, admin_subject): """ List the primary email addresses of all non-suspended Google Workspace users. :param service_account: Email of the directory-reader service account. :type service_account: str :param admin_subject: Workspace admin the service account impersonates. :type admin_subject: str :return: Primary email addresses of active users, lowercased. :rtype: set(str) :raise GoogleNotConfigured: if the Google side is not set up yet. :raise HttpError: on a directory API error that is not a setup problem. """ # pylint: disable=import-outside-toplevel from google.auth.exceptions import RefreshError from googleapiclient.errors import HttpError directory = _directory_client(service_account, admin_subject) users = set() page_token = None try: while True: # query="isSuspended=false" would push the filter server-side, but # listing everyone and filtering here means a user missing from the # response for any reason is never mistaken for a suspended one. # googleapiclient builds resource methods dynamically from the # discovery document, so .users() is invisible to static analysis. response = ( directory.users() # pylint: disable=no-member .list(customer="my_customer", maxResults=500, projection="basic", pageToken=page_token) .execute() ) for user in response.get("users", []): if not user.get("suspended", False): users.add(user["primaryEmail"].lower()) page_token = response.get("nextPageToken") if not page_token: break except RefreshError as err: # Domain-wide delegation has not been authorized for this service # account's client id, so the token exchange is rejected outright. raise GoogleNotConfigured(f"domain-wide delegation is not authorized: {err}") from err except HttpError as err: # 403 typically means delegation exists but the directory scope was not # granted; 401 that the subject is not a real admin. Both are "not # configured yet", not a regression, so they must not page anyone. if err.resp.status in (401, 403): raise GoogleNotConfigured(f"directory API rejected the request ({err.resp.status}): {err}") from err raise return users
[docs]def active_users_across_workspaces(service_account: str, admin_subjects: Iterable[str]) -> Set[str]: """ Union the active users of every Workspace, or fail without a partial answer. The caller revokes certificates whose owner is missing from the returned set, so an incomplete union is worse than no answer at all: users of a Workspace that was skipped would look deactivated and lose their VPN access. Hence a failure to read any single Workspace fails the whole call. :param service_account: Email of the directory-reader service account. The same account impersonates every subject. :type service_account: str :param admin_subjects: Workspace admins to impersonate, one per tenant. :type admin_subjects: Iterable(str) :return: Primary email addresses of users active in at least one Workspace. :rtype: set(str) :raise GoogleNotConfigured: if any Workspace is not readable yet. :raise EmptyDirectory: if any Workspace reports no active users. """ active_users = set() for admin_subject in admin_subjects: workspace_users = get_active_directory_users(service_account, admin_subject) # Refused per Workspace, not on the union: the other tenants' users would # otherwise mask an empty one and its certificates would all be revoked. if not workspace_users: raise EmptyDirectory(f"the directory of {admin_subject} returned no active users") LOG.info("Workspace of %s has %d active user(s).", admin_subject, len(workspace_users)) active_users |= workspace_users return active_users
def _default_admin_subjects() -> Tuple[str, ...]: """ Resolve the admin subjects to impersonate from the environment. ``WIF_ADMIN_SUBJECTS`` (comma-separated, one admin per Workspace tenant) is authoritative. ``WIF_ADMIN_SUBJECT`` is the deprecated single-tenant spelling and is honoured only when the plural variable is absent, so an instance whose Terraform has not been re-applied yet keeps working unchanged. :return: Admin subjects in the order they were configured. :rtype: tuple(str) """ plural = os.environ.get("WIF_ADMIN_SUBJECTS") if plural: return tuple(subject.strip() for subject in plural.split(",") if subject.strip()) singular = os.environ.get("WIF_ADMIN_SUBJECT") return (singular,) if singular else () @click.command(name="sync-google-users") @click.option( "--service-account", help="Directory-reader service account email. Defaults to $WIF_SA_EMAIL.", default=lambda: os.environ.get("WIF_SA_EMAIL"), ) @click.option( "--admin-subject", "admin_subjects", help=( "Workspace admin to impersonate; repeatable, one per Workspace tenant. " "Defaults to $WIF_ADMIN_SUBJECTS (comma-separated) or $WIF_ADMIN_SUBJECT." ), multiple=True, default=_default_admin_subjects, ) @click.option( "--dry-run", help="Report what would be revoked without revoking anything.", is_flag=True, default=False, show_default=True, ) @click.pass_context def cmd_sync_google_users(ctx: click.Context, **kwargs): """ Revoke certificates of users deactivated in Google Workspace. Reconciles the certificate index against the directory: every valid user certificate whose owner is suspended, deleted or otherwise absent is revoked. The server's own certificate is never a candidate. MULTIPLE WORKSPACES are supported: allowed VPN users may live in separate Google Workspace tenants, so --admin-subject is repeatable -- one admin per tenant. The single service account impersonates each admin in turn (its client id must be authorized for domain-wide delegation in every tenant), and the active users of all tenants are unioned before anything is compared. That union is all-or-nothing: if any one tenant cannot be read, the whole run aborts and revokes nothing. Proceeding on a partial union would make every active user of the unreadable tenant look deactivated and revoke them all. CONFIGURATION comes from environment variables, which on a module-provisioned instance are written by Terraform to /opt/openvpn-wif/wif.env (the file holds no secret -- the credential it points at is a keyless federation config, not a key): \b GOOGLE_APPLICATION_CREDENTIALS path to the WIF credential config WIF_SA_EMAIL directory-reader service account WIF_ADMIN_SUBJECTS Workspace admins to impersonate, comma-separated, one per tenant WIF_ADMIN_SUBJECT deprecated single-tenant spelling, used only when WIF_ADMIN_SUBJECTS is unset So a by-hand run on an instance is source-then-invoke. --dry-run first is the safe habit: it prints exactly who would be revoked and touches nothing. Revoking is the default (the scheduled job invokes the command bare); there is no separate "enforce" flag to remember. \b # load the config Terraform wrote . /opt/openvpn-wif/wif.env # preview -- lists deactivated users, revokes nothing ih-openvpn sync-google-users --dry-run # apply ih-openvpn sync-google-users # confirm -- the revoked cert now shows state R ih-openvpn list-clients Exit status is a contract with the Puppet cron wrapper: 0 on success, 78 when the Google side is not usable yet (most often domain-wide delegation not authorized), any other code on a genuine failure. Point --config-dir at a different OpenVPN installation, or override the service account / admin subjects, with the options below. """ config_dir = ctx.obj["config_dir"] service_account = kwargs["service_account"] # Repeats and the same admin arriving from both the option and the # environment only cost a duplicate directory query; dict.fromkeys drops them # while keeping the configured order for readable logs. admin_subjects = tuple(dict.fromkeys(kwargs["admin_subjects"])) dry_run = kwargs["dry_run"] # Absent configuration is the pre-deployment state, not a usage error, so it # exits EX_CONFIG rather than click's UsageError. for name, value in ( ("--service-account/$WIF_SA_EMAIL", service_account), ("--admin-subject/$WIF_ADMIN_SUBJECTS", admin_subjects), ): if not value: LOG.info("%s is not set; the Google integration is not configured yet.", name) sys.exit(EX_CONFIG) certificate_index = index_path(config_dir) try: certificates = valid_user_certificates(certificate_index) except FileNotFoundError: LOG.info("%s does not exist; the PKI is not initialized yet.", certificate_index) sys.exit(EX_CONFIG) try: active_users = active_users_across_workspaces(service_account, admin_subjects) except GoogleNotConfigured as err: # A single Workspace we cannot read makes the union incomplete, and every # active user in it would then look deactivated. Abort the whole run # rather than revoke from a partial picture. LOG.info("Google Workspace is not configured yet: %s", err) sys.exit(EX_CONFIG) except EmptyDirectory as err: # A live query answering with nobody is a failed lookup, not "everyone # was deactivated". LOG.error("%s; refusing to revoke every certificate.", err) sys.exit(1) stale = sorted(certificate for certificate in certificates if certificate.lower() not in active_users) if not stale: LOG.info( "Checked %d certificate(s) against %d active user(s); nothing to revoke.", len(certificates), len(active_users), ) return if dry_run: # Deliberately before any easy-rsa call, so --dry-run cannot touch the # PKI no matter what happens below. LOG.info( "DRY RUN: would revoke %d certificate(s) of deactivated user(s): %s", len(stale), ", ".join(stale), ) LOG.info("DRY RUN: nothing was revoked; re-run without --dry-run to apply.") return LOG.info("Revoking %d certificate(s) of deactivated user(s): %s", len(stale), ", ".join(stale)) failed = [] for common_name in stale: try: revoke_client(ctx.obj["easyrsa_path"], common_name, config_dir) LOG.info("VPN access for client %s is revoked.", common_name) except CalledProcessError as err: # Keep going: one certificate easy-rsa cannot revoke should not # leave the remaining deactivated users with working access. LOG.error("Failed to revoke %s: %s", common_name, err) failed.append(common_name) if failed: LOG.error("Failed to revoke %d certificate(s): %s", len(failed), ", ".join(failed)) sys.exit(1)