# Wallet::ACL::LDAP::Attribute -- Wallet LDAP attribute ACL verifier
#
# Written by Russ Allbery
# Copyright 2016 Russ Allbery <eagle@eyrie.org>
# Copyright 2012-2014
#     The Board of Trustees of the Leland Stanford Junior University
#
# SPDX-License-Identifier: MIT

##############################################################################
# Modules and declarations
##############################################################################

package Wallet::ACL::LDAP::Attribute;

use 5.008;
use strict;
use warnings;

use Authen::SASL;
use Net::LDAP qw(LDAP_COMPARE_TRUE);
use Wallet::ACL::Base;
use Wallet::Config;

our @ISA     = qw(Wallet::ACL::Base);
our $VERSION = '1.06';

##############################################################################
# Interface
##############################################################################

# Create a new persistant verifier.  Load the Net::LDAP module and open a
# persistant LDAP server connection that we'll use for later calls.
sub new {
    my $type = shift;
    my $host = $Wallet::Config::LDAP_HOST;
    my $base = $Wallet::Config::LDAP_BASE;
    unless ($host and defined ($base) and $Wallet::Config::LDAP_CACHE) {
        die "LDAP attribute ACL support not configured\n";
    }

    # Ensure the required Perl modules are available and bind to the directory
    # server.  Catch any errors with a try/catch block.
    my $ldap;
    eval {
        local $ENV{KRB5CCNAME} = $Wallet::Config::LDAP_CACHE;
        my $sasl = Authen::SASL->new (mechanism => 'GSSAPI');
        $ldap = Net::LDAP->new ($host, onerror => 'die');
        my $mesg = eval { $ldap->bind (undef, sasl => $sasl) };
    };
    if ($@) {
        my $error = $@;
        chomp $error;
        1 while ($error =~ s/ at \S+ line \d+\.?\z//);
        die "LDAP attribute ACL support not available: $error\n";
    }

    # We successfully bound, so create our object and return it.
    my $self = { ldap => $ldap };
    bless ($self, $type);
    return $self;
}

# Check whether a given principal has access to the wallet object
# using an LDAP search using a filter consisting of the principal
# and the ldap-attr filter.
#
# If the ldap_map_principal sub is defined in Wallet::Config, call it on the
# principal first to map it to the value for which we'll search.
#
# The connection is configured to die on any error, so we do all the work in a
# try/catch block to report errors.
sub check {
    my ($self, $principal, $acl) = @_;
    undef $self->{error};
    if (!$principal) {
        $self->error ('no principal specified');
        return;
    }

    if (!$acl) {
        $self->error ('no ACL specified');
        return;
    }
    if ($acl !~ /=/xms) {
        $self->error ('Malformed LDAP filter, no equal sign present');
        return;
    }
    my $lcnt = $acl =~ tr/\(//;
    my $rcnt = $acl =~ tr/\)//;
    if ($lcnt != $rcnt) {
        $self->error ('Malformed LDAP filter, parenthesis mismatch');
        return;
    }
    my $attr_filter = $acl;
    if ($attr_filter !~ /^\(/xms) {
        $attr_filter = "($attr_filter)";
    }
    my $ldap = $self->{ldap};

    # Map the principal name to an attribute value for our search if we're
    # doing a custom mapping.
    if (defined &Wallet::Config::ldap_map_principal) {
        eval { $principal = Wallet::Config::ldap_map_principal ($principal) };
        if ($@) {
            $self->error ("mapping principal to LDAP failed: $@");
            return;
        }
    }

    # Now search for one, and only one, matching entry
    my $found;
    my $fattr = $Wallet::Config::LDAP_FILTER_ATTR || 'krb5PrincipalName';
    my $filter = "(&($fattr=$principal)$attr_filter)";
    my $base = $Wallet::Config::LDAP_BASE;
    my @options = (base => $base, filter => $filter, attrs => [ 'dn' ]);
    eval {
        my $search = $ldap->search (@options);
        if ($search->count == 1) {
            $found = 1;
        } elsif ($search->count > 1) {
            die $search->count . " LDAP entries found for $principal";
        }
    };
    if ($@) {
        $self->error ("search for $attr_filter failed in LDAP: $@");
        return;
    }
    if ($found) {
        return 1;
    }

    return;
}

1;

##############################################################################
# Documentation
##############################################################################

=for stopwords
ACL Allbery verifier LDAP PRINCIPAL's DN ldap-attr

=head1 NAME

Wallet::ACL::LDAP::Attribute - Wallet ACL verifier for LDAP attribute compares

=head1 SYNOPSIS

    my $verifier = Wallet::ACL::LDAP::Attribute->new;
    my $status = $verifier->check ($principal, "$attr=$value");
    if (not defined $status) {
        die "Something failed: ", $verifier->error, "\n";
    } elsif ($status) {
        print "Access granted\n";
    } else {
        print "Access denied\n";
    }

=head1 DESCRIPTION

Wallet::ACL::LDAP::Attribute checks whether the LDAP record for the
entry corresponding to a principal contains an attribute with a
particular value.  It is used to verify ACL lines of type
C<ldap-attr>.  The value of such an ACL is a valid LDAP filter, and
the ACL grants access to a given principal if and only if an LDAP
search using a filter constructed of the principal filter AND 
the ACL filter returns a single entry.

To use this object, several configuration parameters must be set.  See
L<Wallet::Config> for details on those configuration parameters and
information about how to set wallet configuration.

=head1 METHODS

=over 4

=item new()

Creates a new ACL verifier.  Opens and binds the connection to the LDAP
server.

=item check(PRINCIPAL, ACL)

Returns true if PRINCIPAL is granted access according to ACL, false if
not, and undef on an error (see L<"DIAGNOSTICS"> below).  ACL must be
a valid LDAP filter.  The filter formed using the PRINCIPAL and the
ACL filter must return a single entry for access to be granted.

=item error()

Returns the error if check() returned undef.

=back

=head1 DIAGNOSTICS

The new() method may fail with one of the following exceptions:

=over 4

=item LDAP attribute ACL support not available: %s

Attempting to connect or bind to the LDAP server failed.

=item LDAP attribute ACL support not configured

The required configuration parameters were not set.  See Wallet::Config(3)
for the required configuration parameters and how to set them.

=back

Verifying an LDAP attribute ACL may fail with the following errors
(returned by the error() method):

=over 4

=item search for %s failed in LDAP: %s

The search for an ldap entry failed because of a configuration error
in Wallet or the LDAP server.  For example the Wallet configuration
includes an invalid root DN.

=item malformed ldap-attr LDAP filter, no equal sign present

The ACL filter stored as ldap-attr is not a valid LDAP filter.

=item malformed ldap-attr LDAP filter, parenthesis mismatch

The ACL filter stored as ldap-attr is not a valid LDAP filter.

=item mapping principal to LDAP failed: %s

There was an ldap_map_principal() function defined in the wallet
configuration, but calling it for the PRINCIPAL argument failed.

=item no ACL specified

The ACL parameter to check() was undefined or the empty string.

=item no principal specified

The PRINCIPAL parameter to check() was undefined or the empty string.

=back

=head1 SEE ALSO

Wallet::ACL(3), Wallet::ACL::Base(3), Wallet::Config(3), wallet-backend(8)

This module is part of the wallet system.  The current version is
available from L<https://www.eyrie.org/~eagle/software/wallet/>.

=head1 AUTHOR

Russ Allbery <eagle@eyrie.org>

=cut
