diff --git a/bin/plugin/open/groupInfo b/bin/plugin/open/groupInfo index 523f2a0..e227f2c 100755 --- a/bin/plugin/open/groupInfo +++ b/bin/plugin/open/groupInfo @@ -13,90 +13,131 @@ use OVH::Bastion::Plugin qw( :DEFAULT help ); # globally allow sys_getpw* and sys_getgr* cache use $ENV{'PW_GR_CACHE'} = 1; -my ($group); +my $withKeys = 1; + +sub toggle_all { + my $v = shift; + $withKeys = $v; + return; +} + my $remainingOptions = OVH::Bastion::Plugin::begin( - argv => \@ARGV, - header => "group info", - options => {'group=s' => \$group}, + + argv => \@ARGV, + header => "group info", + options => { + 'group=s' => \my $group, + 'all' => \my $all, + 'with-keys' => sub { $withKeys = 1 }, + 'without-keys' => sub { $withKeys = 0 }, + 'with-everything' => sub { toggle_all(1) }, + 'without-everything' => sub { toggle_all(0) }, + }, helptext => <<'EOF', Print some basic information about a group -Usage: --osh SCRIPT_NAME --group GROUP +Usage: --osh SCRIPT_NAME <--group GROUP|--all> [OPTIONS] - --group GROUP specify the group to display the infos of + --group GROUP Specify the group to display the info of + --all Dump info for all groups (auditors only), use with ``--json`` + + --with[out]-everything Include or exclude all below options, including future ones + --with[out]-keys Whether to include the group keys list (slow-ish, default: yes) EOF ); -# -# code -# my $fnret; -# -# params check -# -if (!$group) { +if (!$group && !$all) { help(); - osh_exit 'ERR_MISSING_PARAMETER', "Missing 'group' parameter"; + osh_exit 'ERR_MISSING_PARAMETER', "Missing '--group' or '--all' parameter"; } -$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); -$fnret or osh_exit($fnret); - -# get returned untainted value -$group = $fnret->value->{'group'}; -my $shortGroup = $fnret->value->{'shortGroup'}; - -my %roles; -foreach my $role (qw{ member aclkeeper gatekeeper owner }) { - $fnret = OVH::Bastion::is_group_existing(group => $group . ($role eq 'member' ? '' : "-$role")); - if (!$fnret) { - osh_exit($fnret) if $role eq 'member'; # critical - $roles{$role} = []; - } - else { - $roles{$role} = [grep { $_ ne 'allowkeeper' } @{$fnret->value->{'members'} || []}]; - } +my $isAuditor = OVH::Bastion::is_auditor(account => $self); +if (!$isAuditor && $all) { + osh_exit 'ERR_ACCESS_DENIED', "Only bastion auditors can use --all"; } -my $result_hash = {group => $shortGroup}; -$result_hash->{'owners'} = $roles{'owner'}; -$result_hash->{'aclkeepers'} = $roles{'aclkeeper'}; -$result_hash->{'gatekeepers'} = $roles{'gatekeeper'}; +my @groupsToCheck; +if ($all) { + $fnret = OVH::Bastion::get_group_list(); + $fnret or osh_exit($fnret); + @groupsToCheck = sort keys %{$fnret->value}; + osh_info("Gathering data, this may take a few seconds..."); +} +else { + @groupsToCheck = ($group); +} -osh_info "Group " - . $shortGroup - . "'s Owners are: " - . colored(@{$roles{'owner'}} ? join(" ", sort @{$roles{'owner'}}) : '-', "red"); -osh_info "Group " - . $shortGroup - . "'s GateKeepers (managing the members/guests list) are: " - . colored(@{$roles{'gatekeeper'}} ? join(" ", sort @{$roles{'gatekeeper'}}) : '-', "red"); -if ( OVH::Bastion::is_group_owner(group => $shortGroup, account => $self, superowner => 1) - || OVH::Bastion::is_group_gatekeeper(group => $shortGroup, account => $self) - || OVH::Bastion::is_group_aclkeeper(group => $shortGroup, account => $self) - || OVH::Bastion::is_group_member(group => $shortGroup, account => $self) - || OVH::Bastion::is_auditor(account => $self)) -{ +# check all groups and get the untainted data +my @groups; +foreach my $groupName (@groupsToCheck) { + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $groupName, groupType => "key"); + $fnret or osh_exit($fnret); - osh_info "Group " - . $shortGroup - . "'s ACLKeepers (managing the group servers list) are: " - . colored(@{$roles{'aclkeeper'}} ? join(" ", sort @{$roles{'aclkeeper'}}) : '-', "red"); + # get returned untainted value + push @groups, {group => $fnret->value->{'group'}, shortGroup => $fnret->value->{'shortGroup'}}; +} - # now, who is member / guest ? - my (@members, @guests); - foreach my $account (@{$roles{'member'}}) { - osh_debug("what is $account?"); - if ($account =~ /^realm_(.+)/) { - my $pRealm = $1; - $fnret = OVH::Bastion::get_remote_accounts_from_realm(realm => $pRealm); - if (!$fnret || !@{$fnret->value}) { +# gather this only once +$fnret = OVH::Bastion::get_bastion_ips(); +my $from; +if ($fnret) { + my @ips = @{$fnret->value}; + $from = 'from="' . join(',', @ips) . '"'; +} - # we couldn't get the list, or the list is empty: at least show that the realm shared account is there - push @members, $user; - } - else { +# big hash containing all the data we want to return +my %return; + +foreach my $groupData (@groups) { + $group = $groupData->{'group'}; + my $shortGroup = $groupData->{'shortGroup'}; + + # get the member list of each system group mapped to one of the roles of the group + my %roles; + foreach my $role (qw{ member aclkeeper gatekeeper owner }) { + $fnret = OVH::Bastion::is_group_existing(group => $group . ($role eq 'member' ? '' : "-$role")); + if (!$fnret) { + osh_exit($fnret) if $role eq 'member'; # if this happens, we really have a problem here + $roles{$role} = []; + } + else { + $roles{$role} = [grep { $_ ne 'allowkeeper' } @{$fnret->value->{'members'} || []}]; + } + } + + # data that anybody can view: + my %ret = ( + group => $shortGroup, + owners => $roles{'owner'}, + gatekeepers => $roles{'gatekeeper'} + ); + + if ( $isAuditor + || OVH::Bastion::is_group_owner(group => $shortGroup, account => $self, superowner => 1) + || OVH::Bastion::is_group_gatekeeper(group => $shortGroup, account => $self) + || OVH::Bastion::is_group_aclkeeper(group => $shortGroup, account => $self) + || OVH::Bastion::is_group_member(group => $shortGroup, account => $self)) + { + # members, aclkeepers, gatekeepers, owners and auditors can get the aclkeepers list + $ret{'aclkeepers'} = $roles{'aclkeeper'}; + + # being a member of the system group corresponding to the bastion group + # can mean either member or guest, so check this here, taking into account the + # case of the realm accounts + my (@members, @guests); + foreach my $account (@{$roles{'member'}}) { + # realm accounts + if ($account =~ /^realm_(.+)/) { + my $pRealm = $1; + $fnret = OVH::Bastion::get_remote_accounts_from_realm(realm => $pRealm); + if (!$fnret || !@{$fnret->value}) { + # we couldn't get the list, or the list is empty: at least show that the realm shared account is there + push @members, $user; + next; + } + # show remote realm accounts names, either as guests or members foreach my $pRemoteaccount (@{$fnret->value}) { if (OVH::Bastion::is_group_guest(group => $shortGroup, account => "$pRealm/$pRemoteaccount")) { push @guests, "$pRealm/$pRemoteaccount"; @@ -106,141 +147,192 @@ if ( OVH::Bastion::is_group_owner(group => $shortGroup, account => $self, supe } } } - } - else { - - if (OVH::Bastion::is_group_guest(account => $account, group => $shortGroup)) { - push @guests, $account; - } + # normal case (non-realm accounts) else { - push @members, $account; + if (OVH::Bastion::is_group_guest(account => $account, group => $shortGroup)) { + push @guests, $account; + } + else { + push @members, $account; + } + } + } + + # for each guest, get the number of accesses they have on the group, + # so we can show it nicely + my %guest_nb_accesses; + my @filtered_guests; + foreach my $guest (sort @guests) { + $fnret = OVH::Bastion::get_acl_way(way => 'groupguest', group => $shortGroup, account => $guest); + + # for realms, don't show remote accounts with zero accesses, this could be confusing + next if ($guest =~ m{/} && $fnret && @{$fnret->value} == 0); + + $guest_nb_accesses{$guest} = $fnret ? scalar(@{$fnret->value}) : undef; + push @filtered_guests, $guest; + } + + # deprecated in v2.18.00+ + $ret{'full_members'} = \@members; + $ret{'partial_members'} = \@filtered_guests; + # /deprecated + + $ret{'members'} = \@members; + $ret{'guests'} = \@filtered_guests; + $ret{'guests_accesses'} = \%guest_nb_accesses; + + # add a hint about possibly inactive members + my @inactive; + foreach my $account (@members) { + if (OVH::Bastion::is_account_active(account => $account)->is_ko) { + push @inactive, $account; + } + } + $ret{'inactive'} = \@inactive; + + # policies + $fnret = OVH::Bastion::group_config(group => $group, key => 'mfa_required'); + if ($fnret && $fnret->value ne 'none') { + $ret{'mfa_required'} = $fnret->value; + } + + $fnret = OVH::Bastion::group_config(group => $group, key => 'guest_ttl_limit'); + if ($fnret && defined $fnret->value && $fnret->value =~ /^\d+$/) { + $ret{'guest_ttl_limit'} = $fnret->value; + } + + $fnret = OVH::Bastion::group_config(group => $group, %{OVH::Bastion::OPT_GROUP_IDLE_KILL_TIMEOUT()}); + if ($fnret && defined $fnret->value && $fnret->value =~ /^-?\d+$/) { + $ret{'idle_kill_timeout'} = $fnret->value; + } + + $fnret = OVH::Bastion::group_config(group => $group, => %{OVH::Bastion::OPT_GROUP_IDLE_LOCK_TIMEOUT()}); + if ($fnret && defined $fnret->value && $fnret->value =~ /^-?\d+$/) { + $ret{'idle_lock_timeout'} = $fnret->value; + } + } + + # group egress keys if we've been asked those + if ($withKeys) { + $fnret = OVH::Bastion::get_group_keys(group => $group); + if ($fnret and $from) { + foreach my $keyfile (@{$fnret->value->{'sortedKeys'}}) { + my $key = $fnret->value->{'keys'}{$keyfile}; + $key->{'prefix'} = $from; + $ret{'keys'}{$key->{'fingerprint'}} = $key; } } } - osh_info "Group " - . $shortGroup - . "'s Members (with access to ALL the group servers) are: " - . colored(@members ? join(" ", sort @members) : '-', "red"); - my %guest_details; + $return{$shortGroup} = \%ret; + + # print all this in a human-readable format, except if we've been asked + # to dump the data for all groups, in which case the caller will only use + # our JSON output + print_group_info(%ret) if !$all; +} + +sub print_group_info { + my %ret = @_; + my $groupName = $ret{'shortGroup'}; + + osh_info("Group ${groupName}'s Owners are: " + . colored(@{$ret{'owners'}} ? join(" ", sort @{$ret{'owners'}}) : '-', 'red')) + if $ret{'owners'}; + + osh_info("Group ${groupName}'s GateKeepers (managing the members/guests list) are: " + . colored(@{$ret{'gatekeeper'}} ? join(" ", sort @{$ret{'gatekeepers'}}) : '-', 'red')) + if $ret{'gatekeepers'}; + + osh_info("Group ${groupName}'s ACLKeepers (managing the group servers list) are: " + . colored(@{$ret{'aclkeepers'}} ? join(" ", sort @{$ret{'aclkeepers'}}) : '-', 'red')) + if $ret{'aclkeepers'}; + + osh_info("Group ${groupName}'s Members (with access to ALL the group servers) are: " + . colored(@{$ret{'members'}} ? join(" ", sort @{$ret{'members'}}) : '-', 'red')) + if $ret{'members'}; + + # show guest info, along with the number of accesses each guest has my @guest_text; - my @filtered_guests; - foreach my $guest (sort @guests) { - $fnret = OVH::Bastion::get_acl_way(way => 'groupguest', group => $shortGroup, account => $guest); - - # for realms, don't show remote accounts with zero accesses, this could be confusing - next if ($guest =~ m{/} && $fnret && @{$fnret->value} == 0); - $guest_details{$guest} = $fnret ? scalar(@{$fnret->value}) : '?'; - push @guest_text, $guest . "[" . $guest_details{$guest} . "]"; - push @filtered_guests, $guest; + foreach my $guest (@{$ret{'guests'}}) { + my $nb = $ret{'guest_accesse'}{$guest}; + push @guest_text, sprintf("%s[%s]", $guest, $nb // '?'); } - osh_info "Group " - . $shortGroup - . "'s Guests (with access to SOME of the group servers) are: " - . colored(@filtered_guests ? join(" ", @guest_text) : '-', "red"); + osh_info("Group ${groupName}'s Guests (with access to SOME of the group servers) are: " + . colored(@{$ret{'guests'}} ? join(" ", sort @guest_text) : '-', 'red')) + if $ret{'guests'}; - # deprecated in v2.18.00+ - $result_hash->{'full_members'} = \@members; - $result_hash->{'partial_members'} = \@filtered_guests; + # current user doesn't have enough rights to get this info, tell them that + if (!$ret{'members'}) { + osh_info "You should ask them if you think you need access for your work tasks."; + } - # /deprecated + if (@{$ret{'inactive'}}) { + osh_info("For your information, the following accounts are inactive: " + . colored(join(" ", @{$ret{'inactive'}}), "blue")); + } - $result_hash->{'members'} = \@members; - $result_hash->{'guests'} = \@filtered_guests; - $result_hash->{'guests_accesses'} = \%guest_details; + if ($ret{'mfa_required'}) { + my %mfa2text = ( + "any" => "", + "totp" => " (TOTP)", + "password" => " (password)", + ); + osh_warn("MFA Required: when connecting to servers of this group, users will be asked for an " + . "additional authentication factor" + . $mfa2text{$ret{'mfa_required'}}); + } - my @inactive; - foreach my $account (@members) { - if (OVH::Bastion::is_account_active(account => $account)->is_ko) { - push @inactive, $account; + if ($ret{'guest_ttl_limit'}) { + osh_warn("Guest TTL enforced: guest accesses must have a TTL with a maximum duration of " + . OVH::Bastion::duration2human(seconds => $ret{'guest_ttl_limit'})->value->{'duration'}); + } + + if ($ret{'idle_kill_timeout'}) { + my $action = "NOT be cut"; + if ($ret{'idle_kill_timeout'} > 0) { + $action = + "be cut after " . OVH::Bastion::duration2human(seconds => $ret{'idle_kill_timeout'})->value->{'duration'}; } - } - if (@inactive) { - osh_info "For your information, the following accounts are inactive: " . colored(join(" ", @inactive), "blue"); - $result_hash->{'inactive'} = \@inactive; + osh_warn "Specific idle kill timeout: idle sessions on servers of this group will $action"; } - # policies - $fnret = OVH::Bastion::group_config(group => $group, key => 'mfa_required'); - if ($fnret && $fnret->value eq 'password') { - osh_warn - "MFA Required: when connecting to servers of this group, users will be asked for an additional authentication factor (password)"; - } - elsif ($fnret && $fnret->value eq 'totp') { - osh_warn - "MFA Required: when connecting to servers of this group, users will be asked for an additional authentication factor (TOTP)"; - } - elsif ($fnret && $fnret->value eq 'any') { - osh_warn - "MFA Required: When connecting to servers of this group, users will be asked for an additional authentication factor"; - } - if ($fnret && $fnret->value ne 'none') { - $result_hash->{'mfa_required'} = $fnret->value; + if ($ret{'idle_lock_timeout'}) { + my $action = "NOT be locked"; + if ($ret{'idle_lock_timeout'} > 0) { + $action = "be locked after " + . OVH::Bastion::duration2human(seconds => $ret{'idle_lock_timeout'})->value->{'duration'}; + } + osh_warn "Specific idle kill timeout: idle sessions on servers of this group will $action"; } - $fnret = OVH::Bastion::group_config(group => $group, key => 'guest_ttl_limit'); - if ($fnret && defined $fnret->value && $fnret->value =~ /^\d+$/) { - osh_warn "Guest TTL enforced: guest accesses must have a TTL with a maximum duration of " - . OVH::Bastion::duration2human(seconds => $fnret->value)->value->{'duration'}; - $result_hash->{'guest_ttl_limit'} = $fnret->value; - } - - $fnret = OVH::Bastion::group_config(group => $group, %{OVH::Bastion::OPT_GROUP_IDLE_KILL_TIMEOUT()}); - if ($fnret && defined $fnret->value && $fnret->value =~ /^-?\d+$/) { - if ($fnret->value == 0) { - osh_warn "Specific idle kill timeout: idle sessions on servers of this group will NOT be cut"; + if ($withKeys) { + osh_info ' '; + if (!%{$ret{'keys'}}) { + osh_info "This group has no SSH egress key, the owner may use groupGenerateEgressKey to generate one."; + } + elsif (keys %{$ret{'keys'}} == 1) { + osh_info "The public key of this group is:"; } else { - osh_warn "Specific idle kill timeout: idle sessions on servers of this group will be cut after " - . OVH::Bastion::duration2human(seconds => $fnret->value)->value->{'duration'}; + osh_info "The public key of this group are:"; + } + osh_info ' '; + my @sorted = sort { $ret{'keys'}{$a}{'mtime'} <=> $ret{'keys'}{$b}{'mtime'} } keys %{$ret{'keys'}}; + foreach my $fingerprint (@sorted) { + OVH::Bastion::print_public_key(key => $ret{'keys'}{$fingerprint}); } - $result_hash->{'idle_kill_timeout'} = $fnret->value; } - $fnret = OVH::Bastion::group_config(group => $group, => %{OVH::Bastion::OPT_GROUP_IDLE_LOCK_TIMEOUT()}); - if ($fnret && defined $fnret->value && $fnret->value =~ /^-?\d+$/) { - if ($fnret->value == 0) { - osh_warn "Specific idle lock timeout: idle sessions on servers of this group will NOT be locked"; - } - else { - osh_warn "Specific idle lock timeout: idle sessions on servers of this group will be locked after " - . OVH::Bastion::duration2human(seconds => $fnret->value)->value->{'duration'}; - } - $result_hash->{'idle_lock_timeout'} = $fnret->value; - } + return; +} + +if (!$all) { + # only one group, don't return a hash of hash to keep backward compat + my @keys = keys %return; + osh_ok $return{$keys[0]}; } else { - osh_info "You should ask him/her/them if you think you need access for your work tasks."; + osh_info "If you're only seeing this line, you might want to use --json"; + osh_ok \%return; } - -# get pubkeys with the proper from='' and show them - -$fnret = OVH::Bastion::get_bastion_ips(); -my $from; -if ($fnret) { - my @ips = @{$fnret->value}; - $from = 'from="' . join(',', @ips) . '"'; -} - -$fnret = OVH::Bastion::get_group_keys(group => $group); -if ($fnret and $from) { - osh_info ' '; - if ($fnret->value && !@{$fnret->value->{'sortedKeys'}}) { - osh_info "This group has no SSH egress key, the owner may use groupGenerateEgressKey to generate one."; - } - elsif (@{$fnret->value->{'sortedKeys'}} == 1) { - osh_info "The public key of this group is:"; - } - else { - osh_info "The public keys of this group are:"; - } - osh_info ' '; - foreach my $keyfile (@{$fnret->value->{'sortedKeys'}}) { - my $key = $fnret->value->{'keys'}{$keyfile}; - $key->{'prefix'} = $from; - OVH::Bastion::print_public_key(key => $key); - $result_hash->{'keys'}{$key->{'fingerprint'}} = $key; - } -} - -osh_ok $result_hash; diff --git a/bin/plugin/restricted/accountInfo b/bin/plugin/restricted/accountInfo index d54bf12..5aa7f12 100755 --- a/bin/plugin/restricted/accountInfo +++ b/bin/plugin/restricted/accountInfo @@ -15,329 +15,523 @@ use OVH::Bastion::Plugin qw( :DEFAULT help ); # globally allow sys_getpw* and sys_getgr* cache use $ENV{'PW_GR_CACHE'} = 1; +my $withGroups = 0; +my $withPasswordInfo = 0; +my $withEgressKeys = 0; + +sub toggle_all { + my $v = shift; + $withGroups = $v; + $withPasswordInfo = $v; + $withEgressKeys = $v; + return; +} + OVH::Bastion::Plugin::begin( - argv => \@ARGV, - header => "account information", - options => {'account=s' => \my $account, "list-groups" => \my $listGroups}, + argv => \@ARGV, + header => "account information", + options => { + 'account=s' => \my $account, + "all" => \my $all, + "list-groups|with-groups" => sub { $withGroups = 1 }, + "without-groups" => sub { $withGroups = 0 }, + 'with-mfa-password-info' => sub { $withPasswordInfo = 1 }, + 'without-mfa-password-info' => sub { $withPasswordInfo = 0 }, + 'with-egress-keys' => sub { $withEgressKeys = 1 }, + 'without-egress-keys' => sub { $withEgressKeys = 0 }, + 'with-everything' => sub { toggle_all(1) }, + 'without-everything' => sub { toggle_all(0) }, + }, helptext => <<'EOF', Display some information about an account -Usage: --osh SCRIPT_NAME --account ACCOUNT [--list-groups] +Usage: --osh SCRIPT_NAME <--account ACCOUNT|--all> [OPTIONS] - --account ACCOUNT The account name to work on - --list-groups Show which groups the account has a role on + --account ACCOUNT The account name to work on + --all Dump info for all accounts (auditors only), use with ``--json`` + + --with[out]-everything Include or exclude all below options, including future ones + --with[out]-groups Whether to include the groups the account has a role on (SLOW, default: no) + --with[out]-mfa-password-info Whether to include MFA password info of the account (SLOW, auditors only, default: no) + --with[out]-egress-keys Whether to include the account's egress keys (SLOW, auditors only, default: no) EOF ); my $fnret; -$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); -$fnret or osh_exit $fnret; -$account = $fnret->value->{'account'}; -my $sysaccount = $fnret->value->{'sysaccount'}; -my $remoteaccount = $fnret->value->{'remoteaccount'}; +# check params + +if ($account && $all) { + osh_exit('ERR_INCOMPATIBLE_PARAMETERS', msg => "Can't use both --account and --all"); +} + +if (($all || $withPasswordInfo || $withEgressKeys) && !OVH::Bastion::is_auditor(account => $self)) { + osh_exit('ERR_ACCESS_DENIED', msg => "This option can only be used by bastion auditors"); +} + +if (!$account && !$all) { + help(); + osh_exit('ERR_MISSING_PARAMETER', msg => "Missing either --account or --all parameter"); +} + +# gather all accounts if $all, or only use the user-specified account if !$all + +my @accountsToCheck; +if ($all) { + $fnret = OVH::Bastion::get_account_list(); + $fnret or osh_exit $fnret; + + @accountsToCheck = sort keys %{$fnret->value}; + osh_info("Gathering data, this may take a few seconds..."); +} +else { + @accountsToCheck = ($account); +} + +# validate each account and get their corresponding sys/remote name, while also untainting it + +my @accounts; +foreach my $anAccount (@accountsToCheck) { + $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $anAccount); + $fnret or osh_exit $fnret; + $account = $fnret->value->{'account'}; + my $sysaccount = $fnret->value->{'sysaccount'}; + my $remoteaccount = $fnret->value->{'remoteaccount'}; + push @accounts, {account => $account, sysaccount => $sysaccount, remoteaccount => $remoteaccount}; +} + +# load these only once $fnret = OVH::Bastion::get_plugin_list(restrictedOnly => 1); $fnret or osh_exit $fnret; +my @commands = sort keys %{$fnret->value}; -my %ret; -if (OVH::Bastion::is_admin(account => $account)) { - osh_info "$account is a bastion " . colored('admin', 'green'); - $ret{'is_admin'} = 1; -} -if (OVH::Bastion::is_super_owner(account => $account)) { - osh_info "$account is a bastion " . colored('superowner', 'green'); - $ret{'is_superowner'} = 1; -} -if (OVH::Bastion::is_auditor(account => $account)) { - osh_info "$account is a bastion " . colored('auditor', 'green'); - $ret{'is_auditor'} = 1; -} - -osh_info "This account has access to the following restricted commands:"; -my @granted; -foreach my $plugin (sort keys %{$fnret->value}) { - $fnret = OVH::Bastion::is_user_in_group(user => $account, group => "osh-$plugin"); - if ($fnret) { - push @granted, $plugin; - osh_info "- $plugin"; - } -} -if (!@granted) { - osh_info "(none)"; -} -$ret{'allowed_commands'} = \@granted; - -my $result_hash = {}; - -if ($listGroups) { +my @groups; +if ($withGroups) { $fnret = OVH::Bastion::get_group_list(); $fnret or osh_exit $fnret; + @groups = sort keys %{$fnret->value}; +} - osh_info "\nThis account is part of the following groups:"; +# gather info from the account(s) - foreach my $name (sort keys %{$fnret->value}) { - my @flags; - push @flags, 'owner' if OVH::Bastion::is_group_owner(group => $name, account => $account); - push @flags, 'gatekeeper' if OVH::Bastion::is_group_gatekeeper(group => $name, account => $account); - push @flags, 'aclkeeper' if OVH::Bastion::is_group_aclkeeper(group => $name, account => $account); - push @flags, 'member' if OVH::Bastion::is_group_member(group => $name, account => $account); - push @flags, 'guest' if OVH::Bastion::is_group_guest(group => $name, account => $account); - if (@flags) { - my $line = sprintf "%18s", $name; +my %return; +foreach my $accHash (@accounts) { + my %ret; + my ($account, $sysaccount, $remoteaccount) = + ($accHash->{'account'}, $accHash->{'sysaccount'}, $accHash->{'remoteaccount'}); + + $ret{'account'} = $account; + if (OVH::Bastion::is_admin(account => $account)) { + $ret{'is_admin'} = 1; + } + if (OVH::Bastion::is_super_owner(account => $account)) { + $ret{'is_superowner'} = 1; + } + if (OVH::Bastion::is_auditor(account => $account)) { + $ret{'is_auditor'} = 1; + } + + my @granted; + foreach my $plugin (@commands) { + $fnret = OVH::Bastion::is_user_in_group(user => $account, group => "osh-$plugin"); + push @granted, $plugin if $fnret; + } + $ret{'allowed_commands'} = \@granted; + + my $groups_hash = {}; + if ($withGroups) { + foreach my $name (@groups) { + my @flags; + push @flags, 'owner' if OVH::Bastion::is_group_owner(group => $name, account => $account); + push @flags, 'gatekeeper' if OVH::Bastion::is_group_gatekeeper(group => $name, account => $account); + push @flags, 'aclkeeper' if OVH::Bastion::is_group_aclkeeper(group => $name, account => $account); + push @flags, 'member' if OVH::Bastion::is_group_member(group => $name, account => $account); + push @flags, 'guest' if OVH::Bastion::is_group_guest(group => $name, account => $account); + $groups_hash->{$name} = {flags => \@flags, name => $name} if @flags; + } + } + $ret{'groups'} = $groups_hash; + + my $canConnect = 1; + $ret{'always_active'} = OVH::Bastion::account_config( + account => $account, + key => OVH::Bastion::OPT_ACCOUNT_ALWAYS_ACTIVE, + public => 1 + ) ? 1 : 0; + if ($ret{'always_active'}) { + $ret{'is_active'} = 1; + } + else { + $fnret = OVH::Bastion::is_account_active(account => $account); + if ($fnret->is_ok) { + $ret{'is_active'} = 1; + } + elsif ($fnret->is_ko) { + $canConnect = 0; + $ret{'is_active'} = 0; + } + } + + if (OVH::Bastion::is_auditor(account => $self)) { + + # TTL check + + $fnret = OVH::Bastion::is_account_ttl_nonexpired(sysaccount => $sysaccount, account => $account); + if ($fnret->is_ok && $fnret->err eq 'OK_NO_TTL') { + $ret{'is_ttl_set'} = 0; + $ret{'is_ttl_expired'} = 0; + } + elsif ($fnret->is_ok && $fnret->err eq 'OK_TTL_VALID') { + $ret{'is_ttl_set'} = 1; + $ret{'is_ttl_expired'} = 0; + } + elsif ($fnret->is_ko) { + $canConnect = 0; + $ret{'is_ttl_set'} = 1; + $ret{'is_ttl_expired'} = 1; + } + else { + osh_warn "Error getting account TTL expiration info (" . $fnret->msg . ")"; + } + $ret{'ttl_timestamp'} = + ($fnret->value && $fnret->value->{'expiry_time'}) ? $fnret->value->{'expiry_time'} : undef; + + # freeze check + + $fnret = OVH::Bastion::is_account_nonfrozen(account => $account); + $ret{'is_frozen'} = undef; + if ($fnret->is_ok) { + $ret{'is_frozen'} = 0; + } + elsif ($fnret->is_ko) { + $ret{'is_frozen'} = 1; + $ret{'freeze_info'} = $fnret->value; + $canConnect = 0; + } + + # expi check + + $fnret = OVH::Bastion::is_account_nonexpired(sysaccount => $sysaccount, remoteaccount => $remoteaccount); + if ($fnret->is_ok) { + $ret{'is_expired'} = 0; + } + elsif ($fnret->is_ko) { + $canConnect = 0; + $ret{'is_expired'} = 1; + } + else { + osh_warn "Error getting account expiration info (" . $fnret->msg . ")"; + } + + if (!$fnret->is_err) { + $ret{'can_connect'} = $canConnect; + if ($fnret->value->{'already_seen_before'}) { + $ret{'already_seen_before'} = 1; + if (defined $fnret->value->{'seconds'}) { + $fnret = OVH::Bastion::duration2human(seconds => $fnret->value->{'seconds'}, tense => "past"); + if ($fnret) { + $ret{'last_activity'}{$_} = $fnret->value->{$_} for qw{ datetime_local datetime_utc }; + $ret{'last_activity'}{'ago'} = $fnret->value->{'duration'}; + $ret{'last_activity'}{'timestamp'} = time() - $fnret->value->{'seconds'}; + } + } + } + else { + $ret{'already_seen_before'} = 0; + } + } + + $fnret = OVH::Bastion::account_config(account => $account, key => "creation_info"); + if ($fnret) { + my $creation_info; + eval { $creation_info = decode_json($fnret->value); }; + if ($@) { + osh_warn( + "While reading creation metadata information for account '$account', couldn't decode JSON: $@"); + } + else { + $ret{'creation_information'} = $creation_info; + } + } + + $fnret = OVH::Bastion::account_ssh_config_get(account => $account); + if ($fnret->err eq 'OK_EMPTY') { + $ret{'account_egress_ssh_config'}{'type'} = 'default'; + } + elsif ($fnret->err eq 'ERR_FILE_LOCALLY_MODIFIED') { + $ret{'account_egress_ssh_config'}{'type'} = 'locally_modified'; + } + elsif ($fnret) { + $ret{'account_egress_ssh_config'}{'type'} = 'custom'; + foreach my $key (sort keys %{$fnret->value}) { + $ret{'account_egress_ssh_config'}{'items'}{$key} = $fnret->value->{$key}; + } + } + else { + $ret{'account_egress_ssh_config'}{'type'} = 'unknown'; + } + + $fnret = OVH::Bastion::account_config( + account => $account, + public => 1, + key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_POLICY + ); + $ret{'ingress_piv_enforced'} = ($fnret && $fnret->value eq 'yes') ? 1 : 0; # keep for backwards compat + $ret{'ingress_piv_policy'} = $fnret->value || undef; + + $fnret = OVH::Bastion::account_config( + account => $account, + public => 1, + key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE + ); + if ($fnret && $fnret->value > time()) { + my $expiry = $fnret->value - time(); + my $human = OVH::Bastion::duration2human(seconds => $expiry)->value; + $ret{'ingress_piv_grace'} = { + enabled => 1, + expiration_timestamp => $fnret->value, + seconds_remaining => $expiry, + expiration_date => $human->{'date'}, + time_remaining => $human->{'duration'}, + }; + } + else { + $ret{'ingress_piv_grace'} = {enabled => 0}; + } + + $ret{'global_ingress_policy'} = !!OVH::Bastion::config('ingressRequirePIV')->value + 0; + + $ret{'effective_ingress_piv_policy'} = + !!OVH::Bastion::is_effective_piv_account_policy_enabled(account => $account)->is_ok + 0; + + $ret{'mfa_password_required'} = + !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_REQUIRED_GROUP) + 0; + $ret{'mfa_password_bypass'} = + !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_BYPASS_GROUP) + 0; + $ret{'mfa_password_configured'} = + !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP) + 0; + + $ret{'mfa_totp_required'} = + !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_REQUIRED_GROUP) + 0; + $ret{'mfa_totp_bypass'} = + !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_BYPASS_GROUP) + 0; + $ret{'mfa_totp_configured'} = + !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP) + 0; + + $ret{'pam_auth_bypass'} = + !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::PAM_AUTH_BYPASS_GROUP) + 0; + + $ret{'pubkey_auth_optional'} = + !!OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::OSH_PUBKEY_AUTH_OPTIONAL_GROUP) + 0; + + $ret{'personal_egress_mfa_required'} = + OVH::Bastion::account_config(account => $account, key => "personal_egress_mfa_required")->value; + $ret{'personal_egress_mfa_required'} ||= 'none'; # no config means no mfa + + $ret{'idle_ignore'} = !!OVH::Bastion::account_config( + account => $account, + key => OVH::Bastion::OPT_ACCOUNT_IDLE_IGNORE, + public => 1 + ) + 0; + + $ret{'max_inactive_days'} = + OVH::Bastion::account_config(account => $account, %{OVH::Bastion::OPT_ACCOUNT_MAX_INACTIVE_DAYS()})->value; + + if ($withPasswordInfo) { + my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; + push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountGetPasswordInfo'; + push @command, '--account', $account; + $fnret = OVH::Bastion::helper(cmd => \@command); + if ($fnret) { + $ret{'password'}{$_} = $fnret->value->{$_} for (keys %{$fnret->value}); + } + } + } + + $return{$account} = \%ret; + + # print all this in a human-readable format, except if we've been asked + # to dump the data for all accounts, in which case the caller will only use + # our JSON output + print_account_info(%ret) if !$all; +} + +sub print_account_info { + my %ret = @_; + + my $acc = $ret{'account'}; + osh_info("$acc is a bastion " . colored('admin', 'green')) if $ret{'is_admin'}; + osh_info("$account is a bastion " . colored('superowner', 'green')) if $ret{'is_superowner'}; + osh_info("$account is a bastion " . colored('auditor', 'green')) if $ret{'is_auditor'}; + + osh_info "This account has access to the following restricted commands:"; + + osh_info("- $_") for @{$ret{'allowed_commands'}}; + osh_info "(none)" if (!@{$ret{'allowed_commands'}}); + + if ($withGroups) { + osh_info("\nThis account is part of the following groups:"); + foreach my $groupName (sort keys %{$ret{'groups'}}) { + my @flags = @{$ret{'groups'}{$groupName}{'flags'}}; + my $line = sprintf "%18s", $groupName; $line .= sprintf " %14s", colored(grep({ $_ eq 'owner' } @flags) ? 'Owner' : '-', 'red'); $line .= sprintf " %19s", colored(grep({ $_ eq 'gatekeeper' } @flags) ? 'GateKeeper' : '-', 'yellow'); $line .= sprintf " %18s", colored(grep({ $_ eq 'aclkeeper' } @flags) ? 'ACLKeeper' : '-', 'magenta'); $line .= sprintf " %15s", colored(grep({ $_ eq 'member' } @flags) ? 'Member' : '-', 'green'); $line .= sprintf " %14s", colored(grep({ $_ eq 'guest' } @flags) ? 'Guest' : '-', 'cyan'); - osh_info $line; - $result_hash->{$name} = {flags => \@flags, name => $name}; + osh_info($line); } + osh_info("(none)") if not %{$ret{'groups'}}; + osh_info("\n"); } - if (not keys %$result_hash) { - osh_info "(none)"; - } - osh_info "\n"; -} -$ret{'groups'} = $result_hash; - -my $canConnect = 1; -$ret{'always_active'} = OVH::Bastion::account_config( - account => $account, - key => OVH::Bastion::OPT_ACCOUNT_ALWAYS_ACTIVE, - public => 1 -) ? 1 : 0; -if ($ret{'always_active'}) { - $ret{'is_active'} = 1; - osh_info "This account is " . colored('always', 'green') . " active"; -} -else { - $fnret = OVH::Bastion::is_account_active(account => $account); - if ($fnret->is_ok) { - osh_info "\nThis account is " . colored('active', 'green'); - $ret{'is_active'} = 1; + if ($ret{'always_active'}) { + osh_info("This account is " . colored('always', 'green') . " active"); } - elsif ($fnret->is_ko) { + elsif ($ret{'is_active'}) { + osh_info("This account is " . colored('active', 'green')); + } + else { osh_info "\nThis account is " . colored('INACTIVE', 'red'); - $canConnect = 0; - $ret{'is_active'} = 0; - } -} - -if (OVH::Bastion::is_auditor(account => $self)) { - - # TTL check - - $fnret = OVH::Bastion::is_account_ttl_nonexpired(sysaccount => $sysaccount, account => $account); - if ($fnret->is_ok && $fnret->err eq 'OK_NO_TTL') { - osh_info "This account has " . colored('no TTL set', 'green'); - $ret{'is_ttl_expired'} = 0; - } - elsif ($fnret->is_ok && $fnret->err eq 'OK_TTL_VALID') { - osh_info "This account " - . colored('TTL is still valid', 'green') - . " (for " - . $fnret->value->{'details'}{'human'} . ")"; - $ret{'is_ttl_expired'} = 0; - } - elsif ($fnret->is_ko) { - osh_info "This account " - . colored('TTL is EXPIRED', 'red') - . " (since " - . $fnret->value->{'details'}{'human'} . ")"; - $canConnect = 0; - $ret{'is_ttl_expired'} = 1; - } - else { - osh_warn "Error getting account TTL expiration info (" . $fnret->msg . ")"; - } - $ret{'ttl_timestamp'} = ($fnret && $fnret->value) ? $fnret->value->{'expiry_time'} : undef; - - # freeze check - - $fnret = OVH::Bastion::is_account_nonfrozen(account => $account); - $ret{'is_frozen'} = undef; - if ($fnret->is_ok) { - osh_info "This account is " . colored('not frozen', 'green'); - $ret{'is_frozen'} = 0; - } - elsif ($fnret->is_ko) { - my $freezeReason = $fnret->value->{'reason'} || 'no reason given'; - osh_info "This account has been " - . colored('FROZEN', 'red') . " by " - . $fnret->value->{'by'} - . " ($freezeReason)"; - $ret{'is_frozen'} = 1; - $ret{'freeze_info'} = $fnret->value; - $canConnect = 0; } - # expi check - - $fnret = OVH::Bastion::is_account_nonexpired(sysaccount => $sysaccount, remoteaccount => $remoteaccount); - if ($fnret->is_ok) { - osh_info "This account has seen recent-enough activity to " . colored('not be activity-expired', 'green'); - $ret{'is_expired'} = 0; - } - elsif ($fnret->is_ko) { - osh_info "This account is " - . colored('EXPIRED', 'red') - . " activity-wise (it hasn't been seen for a long time)"; - $canConnect = 0; - $ret{'is_expired'} = 1; - } - else { - osh_warn "Error getting account expiration info (" . $fnret->msg . ")"; + if (defined $ret{'is_ttl_set'}) { + my $human = '?'; + if (!$ret{'is_ttl_set'}) { + osh_info("This account has " . colored('no TTL set', 'green')); + } + elsif (!$ret{'is_ttl_expired'}) { + $fnret = OVH::Bastion::duration2human(seconds => $ret{'ttl_timestamp'} - time()); + $human = $fnret->value->{'human'}; + osh_info "This account " . colored('TTL is still valid', 'green') . " (for $human)"; + } + else { + $fnret = OVH::Bastion::duration2human(seconds => time() - $ret{'ttl_timestamp'}, tense => "past"); + $human = $fnret->value->{'human'}; + osh_info "This account " . colored('TTL is EXPIRED', 'red') . " (since $human)"; + } } - if (!$fnret->is_err) { + if (defined $ret{'is_frozen'}) { + if (!$ret{'is_frozen'}) { + osh_info "This account is " . colored('not frozen', 'green'); + } + else { + my $freezeReason = $ret{'freeze_info'}{'reason'} || 'no reason given'; + my $freezeBy = $ret{'freeze_info'}{'by'} || '(unknown)'; + osh_info "This account has been " . colored('FROZEN', 'red') . " by $freezeBy ($freezeReason)"; + } + } + + if (defined $ret{'is_expired'}) { + if ($ret{'is_expired'}) { + osh_info "This account is " + . colored('EXPIRED', 'red') + . " activity-wise (it hasn't been seen for a long time)"; + } + else { + osh_info "This account has seen recent-enough activity to " . colored('not be activity-expired', 'green'); + } + } + + if (defined $ret{'can_connect'}) { osh_info "As a consequence, this account " - . ($canConnect ? colored("can", 'green') : colored("CANNOT", 'red')) + . ($ret{'can_connect'} ? colored("can", 'green') : colored("CANNOT", 'red')) . " connect to this bastion\n\n"; - $ret{'can_connect'} = $canConnect; + } - if ($fnret->value->{'already_seen_before'}) { - $ret{'already_seen_before'} = 1; - if (defined $fnret->value->{'seconds'}) { - $fnret = OVH::Bastion::duration2human(seconds => $fnret->value->{'seconds'}, tense => "past"); - if ($fnret) { - my $seenBeforeStr = $fnret->value->{'datetime_utc'}; - if ( $fnret->value->{'datetime_local'} - && $fnret->value->{'datetime_utc'} ne $fnret->value->{'datetime_local'}) - { - $seenBeforeStr .= " / " . $fnret->value->{'datetime_local'}; - } - $seenBeforeStr = sprintf( - "Last seen on %s (%s ago)", - colored($seenBeforeStr, 'magenta'), - $fnret->value->{'duration'}, - ); - osh_info $seenBeforeStr; - $ret{'last_activity'}{$_} = $fnret->value->{$_} for qw{ datetime_local datetime_utc }; - $ret{'last_activity'}{'ago'} = $fnret->value->{'duration'}; - $ret{'last_activity'}{'timestamp'} = time() - $fnret->value->{'seconds'}; + if (defined $ret{'already_seen_before'}) { + if ($ret{'already_seen_before'}) { + if ($ret{'last_activity'}) { + my $seenBeforeStr = $ret{'last_activity'}{'datetime_utc'}; + if ( $ret{'last_activity'}{'datetime_local'} + && $ret{'last_activity'}{'datetime_utc'} ne $ret{'last_activity'}{'datetime_local'}) + { + $seenBeforeStr .= " / " . $ret{'last_activity'}{'datetime_local'}; } + $seenBeforeStr = sprintf("Last seen on %s (%s ago)", colored($seenBeforeStr, 'magenta'), + $ret{'last_activity'}{'ago'},); + osh_info($seenBeforeStr); + } + else { + osh_info("This account has already been used at least once"); } } else { - osh_info "This account has " . colored('NEVER', 'red') . " been used (yet)"; - $ret{'already_seen_before'} = 0; + osh_info("This account has " . colored('NEVER', 'red') . " been used (yet)"); } } - $fnret = OVH::Bastion::account_config(account => $account, key => "creation_info"); - if ($fnret) { - my $creation_info; - eval { $creation_info = decode_json($fnret->value); }; - if ($@) { - osh_warn("While reading creation metadata information for account '$account', couldn't decode JSON: $@"); + if ($ret{'creation_information'}) { + if ($ret{'creation_information'}{'datetime_utc'}) { + my $createdOnStr = $ret{'creation_information'}{'datetime_utc'}; + if ( $ret{'creation_information'}{'datetime_local'} + && $ret{'creation_information'}{'datetime_utc'} ne $ret{'creation_information'}{'datetime_local'}) + { + $createdOnStr .= " / " . $ret{'creation_information'}{'datetime_local'}; + } + $createdOnStr = sprintf( + "Created on %s (%s ago)", + colored($createdOnStr, 'magenta'), + OVH::Bastion::duration2human(seconds => time() - $ret{'creation_information'}{'timestamp'}) + ->value->{'duration'} + ); + osh_info($createdOnStr); } - else { - $ret{'creation_information'} = $creation_info; - if ($creation_info->{'datetime_utc'}) { - my $createdOnStr = $creation_info->{'datetime_utc'}; - if ( $creation_info->{'datetime_local'} - && $creation_info->{'datetime_utc'} ne $creation_info->{'datetime_local'}) - { - $createdOnStr .= " / " . $creation_info->{'datetime_local'}; - } - $createdOnStr = sprintf( - "Created on %s (%s ago)", - colored($createdOnStr, 'magenta'), - OVH::Bastion::duration2human(seconds => time() - $creation_info->{'timestamp'})->value->{'duration'} - ); - osh_info $createdOnStr; - } - if ($creation_info->{'by'}) { - osh_info "Created by " . colored($creation_info->{'by'}, 'magenta'); - } - if ($creation_info->{'bastion_version'}) { - osh_info "Created using The Bastion " . colored('v' . $creation_info->{'bastion_version'}, 'magenta'); - } - if ($creation_info->{'comment'}) { - osh_info "Creation with the following comment: " . colored($creation_info->{'comment'}, 'magenta'); - } + if ($ret{'creation_information'}{'by'}) { + osh_info("Created by " . colored($ret{'creation_information'}{'by'}, 'magenta')); + } + if ($ret{'creation_information'}{'bastion_version'}) { + osh_info("Created using The Bastion " + . colored('v' . $ret{'creation_information'}{'bastion_version'}, 'magenta')); + } + if ($ret{'creation_information'}{'comment'}) { + osh_info( + "Creation with the following comment: " . colored($ret{'creation_information'}{'comment'}, 'magenta')); } } osh_info "\nAccount egress SSH config:"; - $fnret = OVH::Bastion::account_ssh_config_get(account => $account); - if ($fnret->err eq 'OK_EMPTY') { + if ($ret{'account_egress_ssh_config'}{'type'} eq 'default') { osh_info "- (default)"; - $ret{'account_egress_ssh_config'}{'type'} = 'default'; } - elsif ($fnret->err eq 'ERR_FILE_LOCALLY_MODIFIED') { + elsif ($ret{'account_egress_ssh_config'}{'type'} eq 'locally_modified') { osh_info "- (locally modified!)"; - $ret{'account_egress_ssh_config'}{'type'} = 'locally_modified'; } - elsif ($fnret) { - $ret{'account_egress_ssh_config'}{'type'} = 'custom'; - foreach my $key (sort keys %{$fnret->value}) { - osh_info "- $key " . $fnret->value->{$key}; - $ret{'account_egress_ssh_config'}{'items'}{$key} = $fnret->value->{$key}; + elsif ($ret{'account_egress_ssh_config'}{'type'} eq 'custom') { + foreach my $key (sort keys %{$ret{'account_egress_ssh_config'}{'items'} || {}}) { + osh_info "- $key " . $ret{'account_egress_ssh_config'}{'items'}{$key}; } } else { - $ret{'account_egress_ssh_config'}{'type'} = 'unknown'; - osh_info "- (unknown: " . $fnret . ")"; + osh_info "- (unknown)"; } osh_info "\nAccount PIV-only policy status:"; - $fnret = OVH::Bastion::account_config( - account => $account, - public => 1, - key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_POLICY - ); - $ret{'ingress_piv_enforced'} = ($fnret && $fnret->value eq 'yes') ? 1 : 0; # keep for backwards compat - $ret{'ingress_piv_policy'} = $fnret->value || undef; my $ingress_piv_policy_print = $ret{'ingress_piv_policy'} || 'default'; osh_info "- PIV policy for ingress keys on this account is set to " . colored($ingress_piv_policy_print, $ingress_piv_policy_print eq 'default' ? 'blue' : 'green'); - $fnret = OVH::Bastion::account_config( - account => $account, - public => 1, - key => OVH::Bastion::OPT_ACCOUNT_INGRESS_PIV_GRACE - ); - if ($fnret && $fnret->value > time()) { - my $expiry = $fnret->value - time(); - my $human = OVH::Bastion::duration2human(seconds => $expiry)->value; - osh_info "- PIV grace period for this account is " - . colored('set', 'green') - . " and expires in " - . $human->{'human'}; - $ret{'ingress_piv_grace'} = { - enabled => 1, - expiration_timestamp => $fnret->value, - seconds_remaining => $expiry, - expiration_date => $human->{'date'}, - time_remaining => $human->{'duration'}, - }; + if ($ret{'ingress_piv_grace'} && $ret{'ingress_piv_grace'}{'seconds_remaining'}) { + $fnret = OVH::Bastion::duration2human(seconds => $ret{'ingress_piv_grace'}{'seconds_remaining'})->value; + osh_info("- PIV grace period for this account is " + . colored('set', 'green') + . " and expires in " + . $fnret->value->{'human'}); } else { osh_info "- PIV grace period for this account is " . colored('inactive', 'blue'); - $ret{'ingress_piv_grace'} = {enabled => 0}; } - $fnret = OVH::Bastion::config('ingressRequirePIV')->value; - $ret{'global_ingress_policy'} = $fnret = OVH::Bastion::config('ingressRequirePIV')->value ? 1 : 0; osh_info "- Global PIV policy status is " . ($ret{'global_ingress_policy'} ? colored('enabled', 'red') : colored('disabled', 'blue')); - $fnret = OVH::Bastion::is_effective_piv_account_policy_enabled(account => $account); - $ret{'effective_ingress_piv_policy'} = $fnret->is_ok ? 1 : 0; osh_info "- As a consequence, PIV policy is " . ($ret{'effective_ingress_piv_policy'} ? colored('enforced', 'red') : colored('inactive', 'blue')) . " for this account"; osh_info "\nAccount Multi-Factor Authentication status:"; - $ret{'mfa_password_required'} = - OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_REQUIRED_GROUP) ? 1 : 0; - $ret{'mfa_password_bypass'} = - OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_BYPASS_GROUP) ? 1 : 0; - $ret{'mfa_password_configured'} = - OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_PASSWORD_CONFIGURED_GROUP) ? 1 : 0; osh_info "- Additional password authentication is " . ($ret{'mfa_password_required'} ? colored('required', 'green') : colored('not required', 'blue')) . " for this account"; @@ -347,12 +541,6 @@ if (OVH::Bastion::is_auditor(account => $self)) { osh_info "- Additional password authentication is " . ($ret{'mfa_password_configured'} ? colored('enabled and active', 'green') : colored('disabled', 'blue')); - $ret{'mfa_totp_required'} = - OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_REQUIRED_GROUP) ? 1 : 0; - $ret{'mfa_totp_bypass'} = - OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_BYPASS_GROUP) ? 1 : 0; - $ret{'mfa_totp_configured'} = - OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::MFA_TOTP_CONFIGURED_GROUP) ? 1 : 0; osh_info "- Additional TOTP authentication is " . ($ret{'mfa_totp_required'} ? colored('required', 'green') : colored('not required', 'blue')) . " for this account"; @@ -362,31 +550,18 @@ if (OVH::Bastion::is_auditor(account => $self)) { osh_info "- Additional TOTP authentication is " . ($ret{'mfa_totp_configured'} ? colored('enabled and active', 'green') : colored('disabled', 'blue')); - $ret{'pam_auth_bypass'} = - OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::PAM_AUTH_BYPASS_GROUP) ? 1 : 0; osh_info "- PAM authentication bypass is " . ($ret{'pam_auth_bypass'} ? colored('enabled', 'green') : colored('disabled', 'blue')); - $ret{'pubkey_auth_optional'} = - OVH::Bastion::is_user_in_group(user => $account, group => OVH::Bastion::OSH_PUBKEY_AUTH_OPTIONAL_GROUP) ? 1 : 0; osh_info "- Optional public key authentication is " . ($ret{'pubkey_auth_optional'} ? colored('enabled', 'green') : colored('disabled', 'blue')); - $ret{'personal_egress_mfa_required'} = - OVH::Bastion::account_config(account => $account, key => "personal_egress_mfa_required")->value; - $ret{'personal_egress_mfa_required'} ||= 'none'; # no config means no mfa osh_info "- MFA policy on personal accesses (using personal keys) on egress side is: " . $ret{'personal_egress_mfa_required'}; - $ret{'idle_ignore'} = - OVH::Bastion::account_config(account => $account, key => OVH::Bastion::OPT_ACCOUNT_IDLE_IGNORE, public => 1) - ? 1 - : 0; osh_info "\n- Account is immune to idle counter-measures: " . ($ret{'idle_ignore'} ? colored('yes', 'green') : colored('no', 'blue')); - $ret{'max_inactive_days'} = - OVH::Bastion::account_config(account => $account, %{OVH::Bastion::OPT_ACCOUNT_MAX_INACTIVE_DAYS()})->value; if (!defined $ret{'max_inactive_days'}) { osh_info "- Maximum number of days of inactivity before account is disabled: (default)"; } @@ -397,12 +572,7 @@ if (OVH::Bastion::is_auditor(account => $self)) { osh_info "- Maximum number of days of inactivity before account is disabled: " . $ret{'max_inactive_days'}; } - my @command = qw{ sudo -n -u root -- /usr/bin/env perl -T }; - push @command, $OVH::Bastion::BASEPATH . '/bin/helper/osh-accountGetPasswordInfo'; - push @command, '--account', $account; - $fnret = OVH::Bastion::helper(cmd => \@command); - if ($fnret) { - $ret{'password'}{$_} = $fnret->value->{$_} for (keys %{$fnret->value}); + if ($ret{'password'}) { osh_info "Account PAM UNIX password information (used for password MFA):"; if ($ret{'password'}{'password'} eq 'locked') { osh_info "- No valid password is set"; @@ -432,6 +602,16 @@ if (OVH::Bastion::is_auditor(account => $self)) { } } } + + return; } -osh_ok(\%ret); +if (!$all) { + # only one account, don't return a hash of hash to keep backward compat + my @keys = keys %return; + osh_ok $return{$keys[0]}; +} +else { + osh_info "If you're only seeing this line, you might want to use --json"; + osh_ok \%return; +} diff --git a/doc/sphinx-plugins-override/accountInfo.rst b/doc/sphinx-plugins-override/accountInfo.rst index 273d427..9a76c1d 100644 --- a/doc/sphinx-plugins-override/accountInfo.rst +++ b/doc/sphinx-plugins-override/accountInfo.rst @@ -1,47 +1,71 @@ +Usage examples +============== + +Show info about a specific account:: + + --osh accountInfo --account jdoe12 + +Gather info about all accounts, with no extra data except their egress keys:: + + --osh accountInfo --all --without-everything --with-egress-keys --json + +Gather info about all accounts, including all extra data (and possibly future options):: + + --osh accountInfo --all --with-everything --json + Output example ============== :: - ~ user1 is a bastion admin - ~ user1 is a bastion superowner - ~ user1 is a bastion auditor - ~ user1 has access to the following restricted commands: - ~ - accountCreate - ~ - accountDelete - ~ - groupCreate - ~ - groupDelete - ~ - ~ This account is part of the following groups: - ~ testgroup1 Owner GateKeeper ACLKeeper Member - - ~ gatekeeper-grp2 Owner GateKeeper - - - - ~ - ~ This account is active - ~ This account is not expired - ~ As a consequence, this account can connect to this bastion - ~ - ~ This account has already been used at least once - ~ Last seen on Wed 2020-07-15 12:06:27 UTC (00:00:00 ago) - ~ - ~ Account egress SSH config: - ~ - (default) - ~ - ~ PIV-enforced policy for ingress keys on this account is enabled - ~ - ~ Account Multi-Factor Authentication status: - ~ - Additional password authentication is not required for this account - ~ - Additional password authentication bypass is disabled for this account - ~ - Additional password authentication is enabled and active - ~ - Additional TOTP authentication is not required for this account - ~ - Additional TOTP authentication bypass is disabled for this account - ~ - Additional TOTP authentication is disabled - ~ - PAM authentication bypass is disabled - ~ - Optional public key authentication is disabled - ~ - MFA policy on personal accesses (using personal keys) on egress side is: password + │ user1 is a bastion admin + │ user1 is a bastion superowner + │ user1 is a bastion auditor + │ + │ user1 has access to the following restricted commands: + │ - accountCreate + │ - accountDelete + │ - groupCreate + │ - groupDelete + │ + │ This account is part of the following groups: + │ testgroup1 Owner GateKeeper ACLKeeper Member - + │ gatekeeper-grp2 Owner GateKeeper - - - + │ + │ This account is active + │ This account has no TTL set + │ This account is not frozen + │ This account has seen recent-enough activity to not be activity-expired + │ As a consequence, this account can connect to this bastion + │ + │ Last seen on Thu 2023-03-16 07:51:49 UTC (00:00:00 ago) + │ Created on Fri 2022-06-17 09:52:50 UTC (271d+21:58:59 ago) + │ Created by jdoe + │ Created using The Bastion v3.08.01 + │ + │ Account egress SSH config: + │ - (default) + │ + │ PIV-enforced policy for ingress keys on this account is enabled + │ + │ Account Multi-Factor Authentication status: + │ - Additional password authentication is not required for this account + │ - Additional password authentication bypass is disabled for this account + │ - Additional password authentication is enabled and active + │ - Additional TOTP authentication is not required for this account + │ - Additional TOTP authentication bypass is disabled for this account + │ - Additional TOTP authentication is disabled + │ - PAM authentication bypass is disabled + │ - Optional public key authentication is disabled + │ - MFA policy on personal accesses (using personal keys) on egress side is: password + │ + │ - Account is immune to idle counter-measures: no + │ - Maximum number of days of inactivity before account is disabled: (default) + │ + │ Account PAM UNIX password information (used for password MFA): + │ - Password is set + │ - Password was last changed on 2023-01-27 + │ - Password must be changed every 90 days at least + │ - A warning is displayed 75 days before expiration + │ - Account will not be disabled after password expiration - ~ Account PAM UNIX password information (used for password MFA): - ~ - Password is set - ~ - Password was last changed on 2020-04-27 - ~ - Password must be changed every 90 days at least - ~ - A warning is displayed 75 days before expiration - ~ - Account will not be disabled after password expiration diff --git a/doc/sphinx-plugins-override/groupInfo.rst b/doc/sphinx-plugins-override/groupInfo.rst index d956d45..64baf48 100644 --- a/doc/sphinx-plugins-override/groupInfo.rst +++ b/doc/sphinx-plugins-override/groupInfo.rst @@ -1,18 +1,33 @@ +Usage examples +============== + +Show info about a specific group:: + + --osh groupInfo --group mygroup2 + +Gather info about all groups, with no extra data except their keys:: + + --osh groupInfo --all --without-everything --with-keys --json + +Gather info about all groups, including all extra data (and possibly future options):: + + --osh groupInfo --all --with-everything --json + Output example ============== :: - ~ Group mygroup's Owners are: user1 - ~ Group mygroup's GateKeepers (managing the members/guests list) are: user2 - ~ Group mygroup's ACLKeepers (managing the group servers list) are: user3 - ~ Group mygroup's Members (with access to ALL the group servers) are: user4 - ~ Group mygroup's Guests (with access to SOME of the group servers) are: user5 - ~ - ~ The public key of this group is: - ~ - ~ fingerprint: SHA256:r/PQS4wLdSWqjYsDca8ReKjhq0l9EX+zQgiUR5qKdlc (ED25519-256) [2018/04/16] - ~ keyline follows, please copy the *whole* line: + | Group mygroup's Owners are: user1 + | Group mygroup's GateKeepers (managing the members/guests list) are: user2 + | Group mygroup's ACLKeepers (managing the group servers list) are: user3 + | Group mygroup's Members (with access to ALL the group servers) are: user4 + | Group mygroup's Guests (with access to SOME of the group servers) are: user5 + | + | The public key of this group is: + | + | fingerprint: SHA256:r/PQS4wLdSWqjYsDca8ReKjhq0l9EX+zQgiUR5qKdlc (ED25519-256) [2018/04/16] + | keyline follows, please copy the *whole* line: from="203.0.113.4/32,192.0.2.0/26" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdD60bA3NgaOpRLgcACWfKcAMRQQRyFMppwp5GpHLTB mygroup@testbastion:1523886640 The first paragraph of the output lists the different roles along with the people having these roles. diff --git a/doc/sphinx/build-plugins-help.sh b/doc/sphinx/build-plugins-help.sh index 664328c..007136f 100644 --- a/doc/sphinx/build-plugins-help.sh +++ b/doc/sphinx/build-plugins-help.sh @@ -44,7 +44,7 @@ do else perl "$pluginfile" '' '' '' '' | perl -e 'undef $/; $_=<>; s/\n+$/\n/; print $_' | perl -ne ' if (m{^Usage: (.+)}) { print ".. admonition:: usage\n :class: cmdusage\n\n $1\n\n.. program:: '"$name"'\n\n"; } - elsif (m{^ (-[- ,a-z|/A-Z"'"'"']+) (.+)}) { ($c,$t)=($1,$2); $c=~s/ +$//; print ".. option:: $c\n\n $t\n\n"; } + elsif (m{^ (-[- ,a-z|/A-Z"'"'"'\[\]]+) (.+)}) { ($c,$t)=($1,$2); $c=~s/ +$//; print ".. option:: $c\n\n $t\n\n"; } elsif ($l++ == 0) { chomp; print "$_\n"."="x(length($_))."\n\n"; } else { print "$_"; } ' diff --git a/doc/sphinx/conf.py b/doc/sphinx/conf.py index 8b8b26d..ca3f3f3 100644 --- a/doc/sphinx/conf.py +++ b/doc/sphinx/conf.py @@ -62,6 +62,9 @@ source_suffix = '.rst' # The master toctree document. master_doc = 'index' +# Default syntax highlighting language. +highlight_language = 'shell' + # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # diff --git a/doc/sphinx/plugins/open/groupInfo.rst b/doc/sphinx/plugins/open/groupInfo.rst index bacf3a4..023ebab 100644 --- a/doc/sphinx/plugins/open/groupInfo.rst +++ b/doc/sphinx/plugins/open/groupInfo.rst @@ -9,30 +9,58 @@ Print some basic information about a group .. admonition:: usage :class: cmdusage - --osh groupInfo --group GROUP + --osh groupInfo <--group GROUP|--all> [OPTIONS] .. program:: groupInfo .. option:: --group GROUP - specify the group to display the infos of + Specify the group to display the info of + +.. option:: --all + + Dump info for all groups (auditors only), use with ``--json`` + + +.. option:: --with[out]-everything + + Include or exclude all below options, including future ones + +.. option:: --with[out]-keys + + Whether to include the group keys list (slow-ish, default: yes) + +Usage examples +============== + +Show info about a specific group:: + + --osh groupInfo --group mygroup2 + +Gather info about all groups, with no extra data except their keys:: + + --osh groupInfo --all --without-everything --with-keys --json + +Gather info about all groups, including all extra data (and possibly future options):: + + --osh groupInfo --all --with-everything --json Output example ============== :: - ~ Group mygroup's Owners are: user1 - ~ Group mygroup's GateKeepers (managing the members/guests list) are: user2 - ~ Group mygroup's ACLKeepers (managing the group servers list) are: user3 - ~ Group mygroup's Members (with access to ALL the group servers) are: user4 - ~ Group mygroup's Guests (with access to SOME of the group servers) are: user5 - ~ - ~ The public key of this group is: - ~ - ~ fingerprint: SHA256:r/PQS4wLdSWqjYsDca8ReKjhq0l9EX+zQgiUR5qKdlc (ED25519-256) [2018/04/16] - ~ keyline follows, please copy the *whole* line: + | Group mygroup's Owners are: user1 + | Group mygroup's GateKeepers (managing the members/guests list) are: user2 + | Group mygroup's ACLKeepers (managing the group servers list) are: user3 + | Group mygroup's Members (with access to ALL the group servers) are: user4 + | Group mygroup's Guests (with access to SOME of the group servers) are: user5 + | + | The public key of this group is: + | + | fingerprint: SHA256:r/PQS4wLdSWqjYsDca8ReKjhq0l9EX+zQgiUR5qKdlc (ED25519-256) [2018/04/16] + | keyline follows, please copy the *whole* line: from="203.0.113.4/32,192.0.2.0/26" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILdD60bA3NgaOpRLgcACWfKcAMRQQRyFMppwp5GpHLTB mygroup@testbastion:1523886640 The first paragraph of the output lists the different roles along with the people having these roles. diff --git a/doc/sphinx/plugins/restricted/accountInfo.rst b/doc/sphinx/plugins/restricted/accountInfo.rst index a1c22c6..3878530 100644 --- a/doc/sphinx/plugins/restricted/accountInfo.rst +++ b/doc/sphinx/plugins/restricted/accountInfo.rst @@ -9,7 +9,7 @@ Display some information about an account .. admonition:: usage :class: cmdusage - --osh accountInfo --account ACCOUNT [--list-groups] + --osh accountInfo <--account ACCOUNT|--all> [OPTIONS] .. program:: accountInfo @@ -18,54 +18,95 @@ Display some information about an account The account name to work on -.. option:: --list-groups +.. option:: --all - Show which groups the account has a role on + Dump info for all accounts (auditors only), use with ``--json`` + + +.. option:: --with[out]-everything + + Include or exclude all below options, including future ones + +.. option:: --with[out]-groups + + Whether to include the groups the account has a role on (SLOW, default: no) + +.. option:: --with[out]-mfa-password-info + + Whether to include MFA password info of the account (SLOW, auditors only, default: no) + +.. option:: --with[out]-egress-keys + + Whether to include the account's egress keys (SLOW, auditors only, default: no) + +Usage examples +============== + +Show info about a specific account:: + + --osh accountInfo --account jdoe12 + +Gather info about all accounts, with no extra data except their egress keys:: + + --osh accountInfo --all --without-everything --with-egress-keys --json + +Gather info about all accounts, including all extra data (and possibly future options):: + + --osh accountInfo --all --with-everything --json Output example ============== :: - ~ user1 is a bastion admin - ~ user1 is a bastion superowner - ~ user1 is a bastion auditor - ~ user1 has access to the following restricted commands: - ~ - accountCreate - ~ - accountDelete - ~ - groupCreate - ~ - groupDelete - ~ - ~ This account is part of the following groups: - ~ testgroup1 Owner GateKeeper ACLKeeper Member - - ~ gatekeeper-grp2 Owner GateKeeper - - - - ~ - ~ This account is active - ~ This account is not expired - ~ As a consequence, this account can connect to this bastion - ~ - ~ This account has already been used at least once - ~ Last seen on Wed 2020-07-15 12:06:27 UTC (00:00:00 ago) - ~ - ~ Account egress SSH config: - ~ - (default) - ~ - ~ PIV-enforced policy for ingress keys on this account is enabled - ~ - ~ Account Multi-Factor Authentication status: - ~ - Additional password authentication is not required for this account - ~ - Additional password authentication bypass is disabled for this account - ~ - Additional password authentication is enabled and active - ~ - Additional TOTP authentication is not required for this account - ~ - Additional TOTP authentication bypass is disabled for this account - ~ - Additional TOTP authentication is disabled - ~ - PAM authentication bypass is disabled - ~ - Optional public key authentication is disabled - ~ - MFA policy on personal accesses (using personal keys) on egress side is: password + │ user1 is a bastion admin + │ user1 is a bastion superowner + │ user1 is a bastion auditor + │ + │ user1 has access to the following restricted commands: + │ - accountCreate + │ - accountDelete + │ - groupCreate + │ - groupDelete + │ + │ This account is part of the following groups: + │ testgroup1 Owner GateKeeper ACLKeeper Member - + │ gatekeeper-grp2 Owner GateKeeper - - - + │ + │ This account is active + │ This account has no TTL set + │ This account is not frozen + │ This account has seen recent-enough activity to not be activity-expired + │ As a consequence, this account can connect to this bastion + │ + │ Last seen on Thu 2023-03-16 07:51:49 UTC (00:00:00 ago) + │ Created on Fri 2022-06-17 09:52:50 UTC (271d+21:58:59 ago) + │ Created by jdoe + │ Created using The Bastion v3.08.01 + │ + │ Account egress SSH config: + │ - (default) + │ + │ PIV-enforced policy for ingress keys on this account is enabled + │ + │ Account Multi-Factor Authentication status: + │ - Additional password authentication is not required for this account + │ - Additional password authentication bypass is disabled for this account + │ - Additional password authentication is enabled and active + │ - Additional TOTP authentication is not required for this account + │ - Additional TOTP authentication bypass is disabled for this account + │ - Additional TOTP authentication is disabled + │ - PAM authentication bypass is disabled + │ - Optional public key authentication is disabled + │ - MFA policy on personal accesses (using personal keys) on egress side is: password + │ + │ - Account is immune to idle counter-measures: no + │ - Maximum number of days of inactivity before account is disabled: (default) + │ + │ Account PAM UNIX password information (used for password MFA): + │ - Password is set + │ - Password was last changed on 2023-01-27 + │ - Password must be changed every 90 days at least + │ - A warning is displayed 75 days before expiration + │ - Account will not be disabled after password expiration - ~ Account PAM UNIX password information (used for password MFA): - ~ - Password is set - ~ - Password was last changed on 2020-04-27 - ~ - Password must be changed every 90 days at least - ~ - A warning is displayed 75 days before expiration - ~ - Account will not be disabled after password expiration diff --git a/doc/sphinx/using/piv.rst b/doc/sphinx/using/piv.rst index 3dbd511..b47324f 100644 --- a/doc/sphinx/using/piv.rst +++ b/doc/sphinx/using/piv.rst @@ -148,7 +148,7 @@ The main takeaways are: Global policy ============= -If you want to apply a policy bastion-wide, please refer to the :ref:`ingressRequiresPIV` option. +If you want to apply a policy bastion-wide, please refer to the :ref:`ingressRequirePIV` option. This policy can still be overridden per-account if needed, see above. Temporary grace period diff --git a/tests/functional/tests.d/325-accountinfo.sh b/tests/functional/tests.d/325-accountinfo.sh index f70b513..d6ca4a6 100644 --- a/tests/functional/tests.d/325-accountinfo.sh +++ b/tests/functional/tests.d/325-accountinfo.sh @@ -45,10 +45,10 @@ testsuite_accountinfo() # a0 should see basic info about a2 success a0_accountinfo_a2_basic $a0 --osh accountInfo --account $account2 - json_document '{"error_message":"OK","command":"accountInfo","error_code":"OK","value":{"always_active":1,"is_active":1,"allowed_commands":[],"groups":{}}}' + json_document '{"error_message":"OK","command":"accountInfo","error_code":"OK","value":{"account":"'"$account2"'","always_active":1,"is_active":1,"allowed_commands":[],"groups":{}}}' # a1 should see detailed info about a2 - success a1_accountinfo_a2_detailed $a1 --osh accountInfo --account $account2 + success a1_accountinfo_a2_detailed $a1 --osh accountInfo --account $account2 --with-mfa-password-info json .error_code OK .command accountInfo .value.always_active 1 .value.is_active 1 .value.allowed_commands "[]" json .value.ingress_piv_policy null .value.personal_egress_mfa_required none .value.pam_auth_bypass 0 json .value.password.min_days 0 .value.password.user "$account2" .value.password.password locked @@ -114,10 +114,29 @@ testsuite_accountinfo() revoke accountCreate grant auditor + success a0_accountinfo_a4_max_inactive_days $a0 --osh accountInfo --account $account4 json .value.max_inactive_days 42 + + # take the opportunity to test --all + success a0_accountinfo_all $a0 --osh accountInfo --all + json $(cat <