diff options
Diffstat (limited to 'perl')
| -rw-r--r-- | perl/Wallet/Config.pm | 47 | ||||
| -rw-r--r-- | perl/Wallet/Object/Duo.pm | 331 | ||||
| -rw-r--r-- | perl/Wallet/Schema.pm | 17 | ||||
| -rw-r--r-- | perl/Wallet/Schema/Result/Duo.pm | 53 | ||||
| -rw-r--r-- | perl/sql/Wallet-Schema-0.08-0.09-MySQL.sql | 17 | ||||
| -rw-r--r-- | perl/sql/Wallet-Schema-0.08-0.09-PostgreSQL.sql | 12 | ||||
| -rw-r--r-- | perl/sql/Wallet-Schema-0.08-0.09-SQLite.sql | 11 | ||||
| -rw-r--r-- | perl/sql/Wallet-Schema-0.09-MySQL.sql | 204 | ||||
| -rw-r--r-- | perl/sql/Wallet-Schema-0.09-PostgreSQL.sql | 208 | ||||
| -rw-r--r-- | perl/sql/Wallet-Schema-0.09-SQLite.sql | 212 | ||||
| -rw-r--r-- | perl/t/data/duo/integration.json | 11 | ||||
| -rw-r--r-- | perl/t/data/duo/keys.json | 5 | ||||
| -rwxr-xr-x | perl/t/duo.t | 157 | 
13 files changed, 1283 insertions, 2 deletions
| diff --git a/perl/Wallet/Config.pm b/perl/Wallet/Config.pm index 0d9d506..ed3dded 100644 --- a/perl/Wallet/Config.pm +++ b/perl/Wallet/Config.pm @@ -1,7 +1,7 @@  # Wallet::Config -- Configuration handling for the wallet server.  #  # Written by Russ Allbery <eagle@eyrie.org> -# Copyright 2007, 2008, 2010, 2013 +# Copyright 2007, 2008, 2010, 2013, 2014  #     The Board of Trustees of the Leland Stanford Junior University  #  # See LICENSE for licensing terms. @@ -181,6 +181,51 @@ our $DB_PASSWORD;  =back +=head1 DUO OBJECT CONFIGURATION + +These configuration variables only need to be set if you intend to use the +C<duo> object type (the Wallet::Object::Duo class). + +=over 4 + +=item DUO_AGENT + +If this configuration variable is set, its value should be an object that +is call-compatible with LWP::UserAgent.  This object will be used instead +of LWP::UserAgent to make API calls to Duo.  This is primarily useful for +testing, allowing replacement of the user agent with a mock implementation +so that a test can run without needing a Duo account. + +=cut + +our $DUO_AGENT; + +=item DUO_KEY_FILE + +The path to a file in JSON format that contains the key and hostname data +for the Duo Admin API integration used to manage integrations via wallet. +This file should be in the format expected by the C<key_file> parameter +to the Net::Duo::Admin constructor.  See L<Net::Duo::Admin> for more +information. + +DUO_KEY_FILE must be set to use Duo objects. + +=cut + +our $DUO_KEY_FILE; + +=item DUO_TYPE + +The type of integration to create.  Currently, only one type of integration +can be created by one wallet configuration.  This restriction may be relaxed +in the future.  The default value is C<unix> to create UNIX integrations. + +=cut + +our $DUO_TYPE = 'unix'; + +=back +  =head1 FILE OBJECT CONFIGURATION  These configuration variables only need to be set if you intend to use the diff --git a/perl/Wallet/Object/Duo.pm b/perl/Wallet/Object/Duo.pm new file mode 100644 index 0000000..af2dfab --- /dev/null +++ b/perl/Wallet/Object/Duo.pm @@ -0,0 +1,331 @@ +# Wallet::Object::Duo -- Duo integration object implementation for the wallet. +# +# Written by Russ Allbery <eagle@eyrie.org> +# Copyright 2014 +#     The Board of Trustees of the Leland Stanford Junior University +# +# See LICENSE for licensing terms. + +############################################################################## +# Modules and declarations +############################################################################## + +package Wallet::Object::Duo; +require 5.006; + +use strict; +use vars qw(@ISA $VERSION); + +use JSON; +use Net::Duo::Admin; +use Net::Duo::Admin::Integration; +use Perl6::Slurp qw(slurp); +use Wallet::Config (); +use Wallet::Object::Base; + +@ISA = qw(Wallet::Object::Base); + +# This version should be increased on any code change to this module.  Always +# use two digits for the minor version with a leading zero if necessary so +# that it will sort properly. +$VERSION = '0.01'; + +############################################################################## +# Core methods +############################################################################## + +# Override attr_show to display the Duo integration key attribute. +sub attr_show { +    my ($self) = @_; +    my $output = ''; +    my $key; +    eval { +        my %search = (du_name => $self->{name}); +        my $row = $self->{schema}->resultset ('Duo')->find (\%search); +        $key = $row->get_column ('du_key'); +    }; +    if ($@) { +        $self->error ($@); +        return; +    } +    return sprintf ("%15s: %s\n", 'Duo key', $key); +} + +# Override new to start by creating a Net::Duo::Admin object for subsequent +# calls. +sub new { +    my ($class, $type, $name, $schema) = @_; + +    # We have to have a Duo integration key file set. +    if (not $Wallet::Config::DUO_KEY_FILE) { +        die "duo object implementation not configured\n"; +    } +    my $key_file = $Wallet::Config::DUO_KEY_FILE; +    my $agent    = $Wallet::Config::DUO_AGENT; + +    # Construct the Net::Duo::Admin object. +    require Net::Duo::Admin; +    my $duo = Net::Duo::Admin->new ( +        { +            key_file   => $key_file, +            user_agent => $agent, +        } +    ); + +    # Construct the object. +    my $self = $class->SUPER::new ($type, $name, $schema); +    $self->{duo} = $duo; +    return $self; +} + +# Override create to start by creating a new integration in Duo, and only +# create the entry in the database if that succeeds.  Error handling isn't +# great here since we don't have a way to communicate the error back to the +# caller. +sub create { +    my ($class, $type, $name, $schema, $creator, $host, $time) = @_; + +    # We have to have a Duo integration key file set. +    if (not $Wallet::Config::DUO_KEY_FILE) { +        die "duo object implementation not configured\n"; +    } +    my $key_file = $Wallet::Config::DUO_KEY_FILE; +    my $agent    = $Wallet::Config::DUO_AGENT; + +    # Construct the Net::Duo::Admin object. +    require Net::Duo::Admin; +    my $duo = Net::Duo::Admin->new ( +        { +            key_file   => $key_file, +            user_agent => $agent, +        } +    ); + +    # Create the object in Duo. +    require Net::Duo::Admin::Integration; +    my %data = ( +        name  => $name, +        notes => 'Managed by wallet', +        type  => $Wallet::Config::DUO_TYPE, +    ); +    my $integration = Net::Duo::Admin::Integration->create ($duo, \%data); + +    # Create the object in wallet. +    my @trace = ($creator, $host, $time); +    my $self = $class->SUPER::create ($type, $name, $schema, @trace); +    $self->{duo} = $duo; + +    # Add the integration key to the object metadata. +    my $guard = $self->{schema}->txn_scope_guard; +    eval { +        my %record = ( +            du_name => $name, +            du_key  => $integration->integration_key, +        ); +        $self->{schema}->resultset ('Duo')->create (\%record); +        $guard->commit; +    }; +    if ($@) { +        my $id = $self->{type} . ':' . $self->{name}; +        $self->error ("cannot set Duo key for $id: $@"); +        return; +    } + +    # Done.  Return the object. +    return $self; +} + +# Override destroy to delete the integration out of Duo as well. +sub destroy { +    my ($self, $user, $host, $time) = @_; +    my $id = $self->{type} . ':' . $self->{name}; +    if ($self->flag_check ('locked')) { +        $self->error ("cannot destroy $id: object is locked"); +        return; +    } +    my $schema = $self->{schema}; +    my $guard = $schema->txn_scope_guard; +    eval { +        my %search = (du_name => $self->{name}); +        my $row = $schema->resultset ('Duo')->find (\%search); +        my $key = $row->get_column ('du_key'); +        my $int = Net::Duo::Admin::Integration->new ($self->{duo}, $key); +        $int->delete; +        $row->delete; +        $guard->commit; +    }; +    if ($@) { +        $self->error ($@); +        return; +    } +    return $self->SUPER::destroy ($user, $host, $time); +} + +# Our get implementation.  Retrieve the integration information from Duo and +# construct the configuration file expected by the Duo PAM module. +sub get { +    my ($self, $user, $host, $time) = @_; +    $time ||= time; + +    # Check that the object isn't locked. +    my $id = $self->{type} . ':' . $self->{name}; +    if ($self->flag_check ('locked')) { +        $self->error ("cannot get $id: object is locked"); +        return; +    } + +    # Retrieve the integration from Duo. +    my $key; +    eval { +        my %search = (du_name => $self->{name}); +        my $row = $self->{schema}->resultset ('Duo')->find (\%search); +        $key = $row->get_column ('du_key'); +    }; +    if ($@) { +        $self->error ($@); +        return; +    } +    my $integration = Net::Duo::Admin::Integration->new ($self->{duo}, $key); + +    # We also need the admin server name, which we can get from the Duo object +    # configuration with a bit of JSON decoding. +    my $json = JSON->new->utf8 (1); +    my $config = $json->decode (scalar slurp $Wallet::Config::DUO_KEY_FILE); + +    # Construct the returned file. +    my $output = "[duo]\n"; +    $output .= "ikey = $key\n"; +    $output .= 'skey = ' . $integration->secret_key . "\n"; +    $output .= "host = $config->{api_hostname}\n"; + +    # Log the action and return. +    $self->log_action ('get', $user, $host, $time); +    return $output; +} + +1; +__END__ + +############################################################################## +# Documentation +############################################################################## + +=for stopwords +Allbery Duo integration + +=head1 NAME + +Wallet::Object::Duo - Duo integration object implementation for wallet + +=head1 SYNOPSIS + +    my @name = qw(duo host.example.com); +    my @trace = ($user, $host, time); +    my $object = Wallet::Object::Duo->create (@name, $schema, @trace); +    my $config = $object->get (@trace); +    $object->destroy (@trace); + +=head1 DESCRIPTION + +Wallet::Object::Duo is a representation of Duo integrations the wallet. +It implements the wallet object API and provides the necessary glue to +create a Duo integration, return a configuration file containing the key +and API information for that integration, and delete the integration from +Duo when the wallet object is destroyed. + +Currently, only one configured integration type can be managed by the +wallet, and the integration information is always returned in the +configuration file format expected by the Duo UNIX integration.  The +results of retrieving this object will be text, suitable for putting in +the UNIX integration configuration file, containing the integration key, +secret key, and admin hostname for that integration. + +This object can be retrieved repeatedly without changing the secret key, +matching Duo's native behavior with integrations.  To change the keys of +the integration, delete it and recreate it. + +To use this object, at least one configuration parameter must be set.  See +L<Wallet::Config> for details on supported configuration parameters and +information about how to set wallet configuration. + +=head1 METHODS + +This object mostly inherits from Wallet::Object::Base.  See the +documentation for that class for all generic methods.  Below are only +those methods that are overridden or behave specially for this +implementation. + +=over 4 + +=item create(TYPE, NAME, DBH, PRINCIPAL, HOSTNAME [, DATETIME]) + +This is a class method and should be called on the Wallet::Object::Duo +class.  It creates a new object with the given TYPE and NAME (TYPE is +normally C<duo> and must be for the rest of the wallet system to use the +right class, but this module doesn't check for ease of subclassing), using +DBH as the handle to the wallet metadata database.  PRINCIPAL, HOSTNAME, +and DATETIME are stored as history information.  PRINCIPAL should be the +user who is creating the object.  If DATETIME isn't given, the current +time is used. + +When a new Duo integration object is created, a new integration will be +created in the configured Duo account and the integration key will be +stored in the wallet object.  If the integration already exists, create() +will fail.  The new integration's type is controlled by the DUO_TYPE +configuration variable, which defaults to C<unix>.  See L<Wallet::Config> +for more information. + +If create() fails, it throws an exception. + +=item destroy(PRINCIPAL, HOSTNAME [, DATETIME]) + +Destroys a Duo integration object by removing it from the database and +deleting the integration from Duo.  If deleting the Duo integration fails, +destroy() fails.  Returns true on success and false on failure.  The +caller should call error() to get the error message after a failure. +PRINCIPAL, HOSTNAME, and DATETIME are stored as history information. +PRINCIPAL should be the user who is destroying the object.  If DATETIME +isn't given, the current time is used. + +=item get(PRINCIPAL, HOSTNAME [, DATETIME]) + +Retrieves the configuration information for the Duo integration and +returns that information in the format expected by the configuration file +for the Duo UNIX integration.  Returns undef on failure.  The caller +should call error() to get the error message if get() returns undef. + +The returned configuration look look like: + +    [duo] +    ikey = <integration-key> +    skey = <secret-key> +    host = <api-hostname> + +The C<host> parameter will be taken from the configuration file pointed +to by the DUO_KEY_FILE configuration variable. + +PRINCIPAL, HOSTNAME, and DATETIME are stored as history information. +PRINCIPAL should be the user who is downloading the keytab.  If DATETIME +isn't given, the current time is used. + +=back + +=head1 LIMITATIONS + +Only one Duo account is supported for a given wallet implementation. +Currently, only one Duo integration type is supported as well.  Further +development should expand the available integration types, possibly as +additional wallet object types. + +=head1 SEE ALSO + +Net::Duo(3), Wallet::Config(3), Wallet::Object::Base(3), wallet-backend(8) + +This module is part of the wallet system.  The current version is +available from L<http://www.eyrie.org/~eagle/software/wallet/>. + +=head1 AUTHOR + +Russ Allbery <eagle@eyrie.org> + +=cut diff --git a/perl/Wallet/Schema.pm b/perl/Wallet/Schema.pm index 2176cab..74b4c99 100644 --- a/perl/Wallet/Schema.pm +++ b/perl/Wallet/Schema.pm @@ -18,7 +18,7 @@ use base 'DBIx::Class::Schema';  # This version should be increased on any code change to this module.  Always  # use two digits for the minor version with a leading zero if necessary so  # that it will sort properly. -our $VERSION = '0.08'; +our $VERSION = '0.09';  __PACKAGE__->load_namespaces;  __PACKAGE__->load_components (qw/Schema::Versioned/); @@ -272,6 +272,21 @@ oh_by stores the authenticated identity that made the change, oh_from  stores the host from which they made the change, and oh_on stores the time  the change was made. +=head2 Duo Backend Data + +Duo integration objects store some additional metadata about the +integration to aid in synchronization with Duo. + +  create table duo +     (du_name             varchar(255) +          not null references objects(ob_name), +      du_key              varchar(255) not null); +  create index du_key on duo (du_key); + +du_key holds the Duo integration key, which is the unique name of the +integration within Duo.  Additional data may be added later to represent +the other possible settings within Duo. +  =head2 Keytab Backend Data  The keytab backend has stub support for synchronizing keys with an diff --git a/perl/Wallet/Schema/Result/Duo.pm b/perl/Wallet/Schema/Result/Duo.pm new file mode 100644 index 0000000..80a71dc --- /dev/null +++ b/perl/Wallet/Schema/Result/Duo.pm @@ -0,0 +1,53 @@ +# Wallet schema for Duo metadata. +# +# Written by Jon Robertson <jonrober@stanford.edu> +# Copyright 2014 +#     The Board of Trustees of the Leland Stanford Junior University +# +# See LICENSE for licensing terms. + +package Wallet::Schema::Result::Duo; + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=for stopwords +keytab enctype + +=head1 NAME + +Wallet::Schema::Result::Duo - Wallet schema for Duo metadata + +=head1 DESCRIPTION + +=cut + +__PACKAGE__->table("duo"); + +=head1 ACCESSORS + +=head2 du_name + +  data_type: 'varchar' +  is_nullable: 0 +  size: 255 + +=head2 du_key + +  data_type: 'varchar' +  is_nullable: 0 +  size: 255 + +=cut + +__PACKAGE__->add_columns( +  "du_name", +  { data_type => "varchar", is_nullable => 0, size => 255 }, +  "du_key", +  { data_type => "varchar", is_nullable => 0, size => 255 }, +); +__PACKAGE__->set_primary_key("du_name"); + +1; diff --git a/perl/sql/Wallet-Schema-0.08-0.09-MySQL.sql b/perl/sql/Wallet-Schema-0.08-0.09-MySQL.sql new file mode 100644 index 0000000..acc517e --- /dev/null +++ b/perl/sql/Wallet-Schema-0.08-0.09-MySQL.sql @@ -0,0 +1,17 @@ +-- Convert schema 'sql/Wallet-Schema-0.08-MySQL.sql' to 'Wallet::Schema v0.09':; + +BEGIN; + +SET foreign_key_checks=0; + +CREATE TABLE `duo` ( +  `du_name` varchar(255) NOT NULL, +  `du_key` varchar(255) NOT NULL, +  PRIMARY KEY (`du_name`) +); + +SET foreign_key_checks=1; + + +COMMIT; + diff --git a/perl/sql/Wallet-Schema-0.08-0.09-PostgreSQL.sql b/perl/sql/Wallet-Schema-0.08-0.09-PostgreSQL.sql new file mode 100644 index 0000000..0384f67 --- /dev/null +++ b/perl/sql/Wallet-Schema-0.08-0.09-PostgreSQL.sql @@ -0,0 +1,12 @@ +-- Convert schema 'sql/Wallet-Schema-0.08-PostgreSQL.sql' to 'sql/Wallet-Schema-0.09-PostgreSQL.sql':; + +BEGIN; + +CREATE TABLE "duo" ( +  "du_name" character varying(255) NOT NULL, +  "du_key" character varying(255) NOT NULL, +  PRIMARY KEY ("du_name") +); + +COMMIT; + diff --git a/perl/sql/Wallet-Schema-0.08-0.09-SQLite.sql b/perl/sql/Wallet-Schema-0.08-0.09-SQLite.sql new file mode 100644 index 0000000..9964a17 --- /dev/null +++ b/perl/sql/Wallet-Schema-0.08-0.09-SQLite.sql @@ -0,0 +1,11 @@ +-- Convert schema 'sql/Wallet-Schema-0.08-SQLite.sql' to 'sql/Wallet-Schema-0.09-SQLite.sql':; + +BEGIN; + +CREATE TABLE duo ( +  du_name varchar(255) NOT NULL, +  du_key varchar(255) NOT NULL, +  PRIMARY KEY (du_name) +); + +COMMIT; diff --git a/perl/sql/Wallet-Schema-0.09-MySQL.sql b/perl/sql/Wallet-Schema-0.09-MySQL.sql new file mode 100644 index 0000000..eb582e5 --- /dev/null +++ b/perl/sql/Wallet-Schema-0.09-MySQL.sql @@ -0,0 +1,204 @@ +--  +-- Created by SQL::Translator::Producer::MySQL +-- Created on Fri Jul 11 16:33:47 2014 +--  +SET foreign_key_checks=0; + +DROP TABLE IF EXISTS `acl_history`; + +-- +-- Table: `acl_history` +-- +CREATE TABLE `acl_history` ( +  `ah_id` integer NOT NULL auto_increment, +  `ah_acl` integer NOT NULL, +  `ah_action` varchar(16) NOT NULL, +  `ah_scheme` varchar(32) NULL, +  `ah_identifier` varchar(255) NULL, +  `ah_by` varchar(255) NOT NULL, +  `ah_from` varchar(255) NOT NULL, +  `ah_on` datetime NOT NULL, +  PRIMARY KEY (`ah_id`) +); + +DROP TABLE IF EXISTS `acl_schemes`; + +-- +-- Table: `acl_schemes` +-- +CREATE TABLE `acl_schemes` ( +  `as_name` varchar(32) NOT NULL, +  `as_class` varchar(64) NULL, +  PRIMARY KEY (`as_name`) +) ENGINE=InnoDB; + +DROP TABLE IF EXISTS `acls`; + +-- +-- Table: `acls` +-- +CREATE TABLE `acls` ( +  `ac_id` integer NOT NULL auto_increment, +  `ac_name` varchar(255) NOT NULL, +  PRIMARY KEY (`ac_id`), +  UNIQUE `ac_name` (`ac_name`) +) ENGINE=InnoDB; + +DROP TABLE IF EXISTS `duo`; + +-- +-- Table: `duo` +-- +CREATE TABLE `duo` ( +  `du_name` varchar(255) NOT NULL, +  `du_key` varchar(255) NOT NULL, +  PRIMARY KEY (`du_name`) +); + +DROP TABLE IF EXISTS `enctypes`; + +-- +-- Table: `enctypes` +-- +CREATE TABLE `enctypes` ( +  `en_name` varchar(255) NOT NULL, +  PRIMARY KEY (`en_name`) +); + +DROP TABLE IF EXISTS `flags`; + +-- +-- Table: `flags` +-- +CREATE TABLE `flags` ( +  `fl_type` varchar(16) NOT NULL, +  `fl_name` varchar(255) NOT NULL, +  `fl_flag` enum('locked', 'unchanging') NOT NULL, +  PRIMARY KEY (`fl_type`, `fl_name`, `fl_flag`) +); + +DROP TABLE IF EXISTS `keytab_enctypes`; + +-- +-- Table: `keytab_enctypes` +-- +CREATE TABLE `keytab_enctypes` ( +  `ke_name` varchar(255) NOT NULL, +  `ke_enctype` varchar(255) NOT NULL, +  PRIMARY KEY (`ke_name`, `ke_enctype`) +); + +DROP TABLE IF EXISTS `keytab_sync`; + +-- +-- Table: `keytab_sync` +-- +CREATE TABLE `keytab_sync` ( +  `ks_name` varchar(255) NOT NULL, +  `ks_target` varchar(255) NOT NULL, +  PRIMARY KEY (`ks_name`, `ks_target`) +); + +DROP TABLE IF EXISTS `sync_targets`; + +-- +-- Table: `sync_targets` +-- +CREATE TABLE `sync_targets` ( +  `st_name` varchar(255) NOT NULL, +  PRIMARY KEY (`st_name`) +); + +DROP TABLE IF EXISTS `types`; + +-- +-- Table: `types` +-- +CREATE TABLE `types` ( +  `ty_name` varchar(16) NOT NULL, +  `ty_class` varchar(64) NULL, +  PRIMARY KEY (`ty_name`) +) ENGINE=InnoDB; + +DROP TABLE IF EXISTS `acl_entries`; + +-- +-- Table: `acl_entries` +-- +CREATE TABLE `acl_entries` ( +  `ae_id` integer NOT NULL, +  `ae_scheme` varchar(32) NOT NULL, +  `ae_identifier` varchar(255) NOT NULL, +  INDEX `acl_entries_idx_ae_scheme` (`ae_scheme`), +  INDEX `acl_entries_idx_ae_id` (`ae_id`), +  PRIMARY KEY (`ae_id`, `ae_scheme`, `ae_identifier`), +  CONSTRAINT `acl_entries_fk_ae_scheme` FOREIGN KEY (`ae_scheme`) REFERENCES `acl_schemes` (`as_name`), +  CONSTRAINT `acl_entries_fk_ae_id` FOREIGN KEY (`ae_id`) REFERENCES `acls` (`ac_id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB; + +DROP TABLE IF EXISTS `objects`; + +-- +-- Table: `objects` +-- +CREATE TABLE `objects` ( +  `ob_type` varchar(16) NOT NULL, +  `ob_name` varchar(255) NOT NULL, +  `ob_owner` integer NULL, +  `ob_acl_get` integer NULL, +  `ob_acl_store` integer NULL, +  `ob_acl_show` integer NULL, +  `ob_acl_destroy` integer NULL, +  `ob_acl_flags` integer NULL, +  `ob_expires` datetime NULL, +  `ob_created_by` varchar(255) NOT NULL, +  `ob_created_from` varchar(255) NOT NULL, +  `ob_created_on` datetime NOT NULL, +  `ob_stored_by` varchar(255) NULL, +  `ob_stored_from` varchar(255) NULL, +  `ob_stored_on` datetime NULL, +  `ob_downloaded_by` varchar(255) NULL, +  `ob_downloaded_from` varchar(255) NULL, +  `ob_downloaded_on` datetime NULL, +  `ob_comment` varchar(255) NULL, +  INDEX `objects_idx_ob_acl_destroy` (`ob_acl_destroy`), +  INDEX `objects_idx_ob_acl_flags` (`ob_acl_flags`), +  INDEX `objects_idx_ob_acl_get` (`ob_acl_get`), +  INDEX `objects_idx_ob_owner` (`ob_owner`), +  INDEX `objects_idx_ob_acl_show` (`ob_acl_show`), +  INDEX `objects_idx_ob_acl_store` (`ob_acl_store`), +  INDEX `objects_idx_ob_type` (`ob_type`), +  PRIMARY KEY (`ob_name`, `ob_type`), +  CONSTRAINT `objects_fk_ob_acl_destroy` FOREIGN KEY (`ob_acl_destroy`) REFERENCES `acls` (`ac_id`) ON DELETE CASCADE ON UPDATE CASCADE, +  CONSTRAINT `objects_fk_ob_acl_flags` FOREIGN KEY (`ob_acl_flags`) REFERENCES `acls` (`ac_id`) ON DELETE CASCADE ON UPDATE CASCADE, +  CONSTRAINT `objects_fk_ob_acl_get` FOREIGN KEY (`ob_acl_get`) REFERENCES `acls` (`ac_id`) ON DELETE CASCADE ON UPDATE CASCADE, +  CONSTRAINT `objects_fk_ob_owner` FOREIGN KEY (`ob_owner`) REFERENCES `acls` (`ac_id`) ON DELETE CASCADE ON UPDATE CASCADE, +  CONSTRAINT `objects_fk_ob_acl_show` FOREIGN KEY (`ob_acl_show`) REFERENCES `acls` (`ac_id`) ON DELETE CASCADE ON UPDATE CASCADE, +  CONSTRAINT `objects_fk_ob_acl_store` FOREIGN KEY (`ob_acl_store`) REFERENCES `acls` (`ac_id`) ON DELETE CASCADE ON UPDATE CASCADE, +  CONSTRAINT `objects_fk_ob_type` FOREIGN KEY (`ob_type`) REFERENCES `types` (`ty_name`) +) ENGINE=InnoDB; + +DROP TABLE IF EXISTS `object_history`; + +-- +-- Table: `object_history` +-- +CREATE TABLE `object_history` ( +  `oh_id` integer NOT NULL auto_increment, +  `oh_type` varchar(16) NOT NULL, +  `oh_name` varchar(255) NOT NULL, +  `oh_action` varchar(16) NOT NULL, +  `oh_field` varchar(16) NULL, +  `oh_type_field` varchar(255) NULL, +  `oh_old` varchar(255) NULL, +  `oh_new` varchar(255) NULL, +  `oh_by` varchar(255) NOT NULL, +  `oh_from` varchar(255) NOT NULL, +  `oh_on` datetime NOT NULL, +  INDEX `object_history_idx_oh_type_oh_name` (`oh_type`, `oh_name`), +  PRIMARY KEY (`oh_id`), +  CONSTRAINT `object_history_fk_oh_type_oh_name` FOREIGN KEY (`oh_type`, `oh_name`) REFERENCES `objects` (`ob_type`, `ob_name`) +) ENGINE=InnoDB; + +SET foreign_key_checks=1; + diff --git a/perl/sql/Wallet-Schema-0.09-PostgreSQL.sql b/perl/sql/Wallet-Schema-0.09-PostgreSQL.sql new file mode 100644 index 0000000..a7b8881 --- /dev/null +++ b/perl/sql/Wallet-Schema-0.09-PostgreSQL.sql @@ -0,0 +1,208 @@ +--  +-- Created by SQL::Translator::Producer::PostgreSQL +-- Created on Fri Jul 11 16:33:49 2014 +--  +-- +-- Table: duo. +-- +DROP TABLE "duo" CASCADE; +CREATE TABLE "duo" ( +  "du_name" character varying(255) NOT NULL, +  "du_key" character varying(255) NOT NULL, +  PRIMARY KEY ("du_name") +); + +-- +-- Table: acl_history. +-- +DROP TABLE "acl_history" CASCADE; +CREATE TABLE "acl_history" ( +  "ah_id" serial NOT NULL, +  "ah_acl" integer NOT NULL, +  "ah_action" character varying(16) NOT NULL, +  "ah_scheme" character varying(32), +  "ah_identifier" character varying(255), +  "ah_by" character varying(255) NOT NULL, +  "ah_from" character varying(255) NOT NULL, +  "ah_on" timestamp NOT NULL, +  PRIMARY KEY ("ah_id") +); + +-- +-- Table: acl_schemes. +-- +DROP TABLE "acl_schemes" CASCADE; +CREATE TABLE "acl_schemes" ( +  "as_name" character varying(32) NOT NULL, +  "as_class" character varying(64), +  PRIMARY KEY ("as_name") +); + +-- +-- Table: acls. +-- +DROP TABLE "acls" CASCADE; +CREATE TABLE "acls" ( +  "ac_id" serial NOT NULL, +  "ac_name" character varying(255) NOT NULL, +  PRIMARY KEY ("ac_id"), +  CONSTRAINT "ac_name" UNIQUE ("ac_name") +); + +-- +-- Table: enctypes. +-- +DROP TABLE "enctypes" CASCADE; +CREATE TABLE "enctypes" ( +  "en_name" character varying(255) NOT NULL, +  PRIMARY KEY ("en_name") +); + +-- +-- Table: flags. +-- +DROP TABLE "flags" CASCADE; +CREATE TABLE "flags" ( +  "fl_type" character varying(16) NOT NULL, +  "fl_name" character varying(255) NOT NULL, +  "fl_flag" character varying NOT NULL, +  PRIMARY KEY ("fl_type", "fl_name", "fl_flag") +); + +-- +-- Table: keytab_enctypes. +-- +DROP TABLE "keytab_enctypes" CASCADE; +CREATE TABLE "keytab_enctypes" ( +  "ke_name" character varying(255) NOT NULL, +  "ke_enctype" character varying(255) NOT NULL, +  PRIMARY KEY ("ke_name", "ke_enctype") +); + +-- +-- Table: keytab_sync. +-- +DROP TABLE "keytab_sync" CASCADE; +CREATE TABLE "keytab_sync" ( +  "ks_name" character varying(255) NOT NULL, +  "ks_target" character varying(255) NOT NULL, +  PRIMARY KEY ("ks_name", "ks_target") +); + +-- +-- Table: sync_targets. +-- +DROP TABLE "sync_targets" CASCADE; +CREATE TABLE "sync_targets" ( +  "st_name" character varying(255) NOT NULL, +  PRIMARY KEY ("st_name") +); + +-- +-- Table: types. +-- +DROP TABLE "types" CASCADE; +CREATE TABLE "types" ( +  "ty_name" character varying(16) NOT NULL, +  "ty_class" character varying(64), +  PRIMARY KEY ("ty_name") +); + +-- +-- Table: acl_entries. +-- +DROP TABLE "acl_entries" CASCADE; +CREATE TABLE "acl_entries" ( +  "ae_id" integer NOT NULL, +  "ae_scheme" character varying(32) NOT NULL, +  "ae_identifier" character varying(255) NOT NULL, +  PRIMARY KEY ("ae_id", "ae_scheme", "ae_identifier") +); +CREATE INDEX "acl_entries_idx_ae_scheme" on "acl_entries" ("ae_scheme"); +CREATE INDEX "acl_entries_idx_ae_id" on "acl_entries" ("ae_id"); + +-- +-- Table: objects. +-- +DROP TABLE "objects" CASCADE; +CREATE TABLE "objects" ( +  "ob_type" character varying(16) NOT NULL, +  "ob_name" character varying(255) NOT NULL, +  "ob_owner" integer, +  "ob_acl_get" integer, +  "ob_acl_store" integer, +  "ob_acl_show" integer, +  "ob_acl_destroy" integer, +  "ob_acl_flags" integer, +  "ob_expires" timestamp, +  "ob_created_by" character varying(255) NOT NULL, +  "ob_created_from" character varying(255) NOT NULL, +  "ob_created_on" timestamp NOT NULL, +  "ob_stored_by" character varying(255), +  "ob_stored_from" character varying(255), +  "ob_stored_on" timestamp, +  "ob_downloaded_by" character varying(255), +  "ob_downloaded_from" character varying(255), +  "ob_downloaded_on" timestamp, +  "ob_comment" character varying(255), +  PRIMARY KEY ("ob_name", "ob_type") +); +CREATE INDEX "objects_idx_ob_acl_destroy" on "objects" ("ob_acl_destroy"); +CREATE INDEX "objects_idx_ob_acl_flags" on "objects" ("ob_acl_flags"); +CREATE INDEX "objects_idx_ob_acl_get" on "objects" ("ob_acl_get"); +CREATE INDEX "objects_idx_ob_owner" on "objects" ("ob_owner"); +CREATE INDEX "objects_idx_ob_acl_show" on "objects" ("ob_acl_show"); +CREATE INDEX "objects_idx_ob_acl_store" on "objects" ("ob_acl_store"); +CREATE INDEX "objects_idx_ob_type" on "objects" ("ob_type"); + +-- +-- Table: object_history. +-- +DROP TABLE "object_history" CASCADE; +CREATE TABLE "object_history" ( +  "oh_id" serial NOT NULL, +  "oh_type" character varying(16) NOT NULL, +  "oh_name" character varying(255) NOT NULL, +  "oh_action" character varying(16) NOT NULL, +  "oh_field" character varying(16), +  "oh_type_field" character varying(255), +  "oh_old" character varying(255), +  "oh_new" character varying(255), +  "oh_by" character varying(255) NOT NULL, +  "oh_from" character varying(255) NOT NULL, +  "oh_on" timestamp NOT NULL, +  PRIMARY KEY ("oh_id") +); +CREATE INDEX "object_history_idx_oh_type_oh_name" on "object_history" ("oh_type", "oh_name"); + +-- +-- Foreign Key Definitions +-- + +ALTER TABLE "acl_entries" ADD CONSTRAINT "acl_entries_fk_ae_scheme" FOREIGN KEY ("ae_scheme") +  REFERENCES "acl_schemes" ("as_name") DEFERRABLE; + +ALTER TABLE "acl_entries" ADD CONSTRAINT "acl_entries_fk_ae_id" FOREIGN KEY ("ae_id") +  REFERENCES "acls" ("ac_id") ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; + +ALTER TABLE "objects" ADD CONSTRAINT "objects_fk_ob_acl_destroy" FOREIGN KEY ("ob_acl_destroy") +  REFERENCES "acls" ("ac_id") ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; + +ALTER TABLE "objects" ADD CONSTRAINT "objects_fk_ob_acl_flags" FOREIGN KEY ("ob_acl_flags") +  REFERENCES "acls" ("ac_id") ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; + +ALTER TABLE "objects" ADD CONSTRAINT "objects_fk_ob_acl_get" FOREIGN KEY ("ob_acl_get") +  REFERENCES "acls" ("ac_id") ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; + +ALTER TABLE "objects" ADD CONSTRAINT "objects_fk_ob_owner" FOREIGN KEY ("ob_owner") +  REFERENCES "acls" ("ac_id") ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; + +ALTER TABLE "objects" ADD CONSTRAINT "objects_fk_ob_acl_show" FOREIGN KEY ("ob_acl_show") +  REFERENCES "acls" ("ac_id") ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; + +ALTER TABLE "objects" ADD CONSTRAINT "objects_fk_ob_acl_store" FOREIGN KEY ("ob_acl_store") +  REFERENCES "acls" ("ac_id") ON DELETE CASCADE ON UPDATE CASCADE DEFERRABLE; + +ALTER TABLE "objects" ADD CONSTRAINT "objects_fk_ob_type" FOREIGN KEY ("ob_type") +  REFERENCES "types" ("ty_name") DEFERRABLE; + diff --git a/perl/sql/Wallet-Schema-0.09-SQLite.sql b/perl/sql/Wallet-Schema-0.09-SQLite.sql new file mode 100644 index 0000000..fbde466 --- /dev/null +++ b/perl/sql/Wallet-Schema-0.09-SQLite.sql @@ -0,0 +1,212 @@ +--  +-- Created by SQL::Translator::Producer::SQLite +-- Created on Fri Jul 11 16:33:48 2014 +--  + +BEGIN TRANSACTION; + +-- +-- Table: duo +-- +DROP TABLE IF EXISTS duo; + +CREATE TABLE duo ( +  du_name varchar(255) NOT NULL, +  du_key varchar(255) NOT NULL, +  PRIMARY KEY (du_name) +); + +-- +-- Table: acl_history +-- +DROP TABLE IF EXISTS acl_history; + +CREATE TABLE acl_history ( +  ah_id INTEGER PRIMARY KEY NOT NULL, +  ah_acl integer NOT NULL, +  ah_action varchar(16) NOT NULL, +  ah_scheme varchar(32), +  ah_identifier varchar(255), +  ah_by varchar(255) NOT NULL, +  ah_from varchar(255) NOT NULL, +  ah_on datetime NOT NULL +); + +-- +-- Table: acl_schemes +-- +DROP TABLE IF EXISTS acl_schemes; + +CREATE TABLE acl_schemes ( +  as_name varchar(32) NOT NULL, +  as_class varchar(64), +  PRIMARY KEY (as_name) +); + +-- +-- Table: acls +-- +DROP TABLE IF EXISTS acls; + +CREATE TABLE acls ( +  ac_id INTEGER PRIMARY KEY NOT NULL, +  ac_name varchar(255) NOT NULL +); + +CREATE UNIQUE INDEX ac_name ON acls (ac_name); + +-- +-- Table: enctypes +-- +DROP TABLE IF EXISTS enctypes; + +CREATE TABLE enctypes ( +  en_name varchar(255) NOT NULL, +  PRIMARY KEY (en_name) +); + +-- +-- Table: flags +-- +DROP TABLE IF EXISTS flags; + +CREATE TABLE flags ( +  fl_type varchar(16) NOT NULL, +  fl_name varchar(255) NOT NULL, +  fl_flag enum NOT NULL, +  PRIMARY KEY (fl_type, fl_name, fl_flag) +); + +-- +-- Table: keytab_enctypes +-- +DROP TABLE IF EXISTS keytab_enctypes; + +CREATE TABLE keytab_enctypes ( +  ke_name varchar(255) NOT NULL, +  ke_enctype varchar(255) NOT NULL, +  PRIMARY KEY (ke_name, ke_enctype) +); + +-- +-- Table: keytab_sync +-- +DROP TABLE IF EXISTS keytab_sync; + +CREATE TABLE keytab_sync ( +  ks_name varchar(255) NOT NULL, +  ks_target varchar(255) NOT NULL, +  PRIMARY KEY (ks_name, ks_target) +); + +-- +-- Table: sync_targets +-- +DROP TABLE IF EXISTS sync_targets; + +CREATE TABLE sync_targets ( +  st_name varchar(255) NOT NULL, +  PRIMARY KEY (st_name) +); + +-- +-- Table: types +-- +DROP TABLE IF EXISTS types; + +CREATE TABLE types ( +  ty_name varchar(16) NOT NULL, +  ty_class varchar(64), +  PRIMARY KEY (ty_name) +); + +-- +-- Table: acl_entries +-- +DROP TABLE IF EXISTS acl_entries; + +CREATE TABLE acl_entries ( +  ae_id integer NOT NULL, +  ae_scheme varchar(32) NOT NULL, +  ae_identifier varchar(255) NOT NULL, +  PRIMARY KEY (ae_id, ae_scheme, ae_identifier), +  FOREIGN KEY (ae_scheme) REFERENCES acl_schemes(as_name), +  FOREIGN KEY (ae_id) REFERENCES acls(ac_id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX acl_entries_idx_ae_scheme ON acl_entries (ae_scheme); + +CREATE INDEX acl_entries_idx_ae_id ON acl_entries (ae_id); + +-- +-- Table: objects +-- +DROP TABLE IF EXISTS objects; + +CREATE TABLE objects ( +  ob_type varchar(16) NOT NULL, +  ob_name varchar(255) NOT NULL, +  ob_owner integer, +  ob_acl_get integer, +  ob_acl_store integer, +  ob_acl_show integer, +  ob_acl_destroy integer, +  ob_acl_flags integer, +  ob_expires datetime, +  ob_created_by varchar(255) NOT NULL, +  ob_created_from varchar(255) NOT NULL, +  ob_created_on datetime NOT NULL, +  ob_stored_by varchar(255), +  ob_stored_from varchar(255), +  ob_stored_on datetime, +  ob_downloaded_by varchar(255), +  ob_downloaded_from varchar(255), +  ob_downloaded_on datetime, +  ob_comment varchar(255), +  PRIMARY KEY (ob_name, ob_type), +  FOREIGN KEY (ob_acl_destroy) REFERENCES acls(ac_id) ON DELETE CASCADE ON UPDATE CASCADE, +  FOREIGN KEY (ob_acl_flags) REFERENCES acls(ac_id) ON DELETE CASCADE ON UPDATE CASCADE, +  FOREIGN KEY (ob_acl_get) REFERENCES acls(ac_id) ON DELETE CASCADE ON UPDATE CASCADE, +  FOREIGN KEY (ob_owner) REFERENCES acls(ac_id) ON DELETE CASCADE ON UPDATE CASCADE, +  FOREIGN KEY (ob_acl_show) REFERENCES acls(ac_id) ON DELETE CASCADE ON UPDATE CASCADE, +  FOREIGN KEY (ob_acl_store) REFERENCES acls(ac_id) ON DELETE CASCADE ON UPDATE CASCADE, +  FOREIGN KEY (ob_type) REFERENCES types(ty_name) +); + +CREATE INDEX objects_idx_ob_acl_destroy ON objects (ob_acl_destroy); + +CREATE INDEX objects_idx_ob_acl_flags ON objects (ob_acl_flags); + +CREATE INDEX objects_idx_ob_acl_get ON objects (ob_acl_get); + +CREATE INDEX objects_idx_ob_owner ON objects (ob_owner); + +CREATE INDEX objects_idx_ob_acl_show ON objects (ob_acl_show); + +CREATE INDEX objects_idx_ob_acl_store ON objects (ob_acl_store); + +CREATE INDEX objects_idx_ob_type ON objects (ob_type); + +-- +-- Table: object_history +-- +DROP TABLE IF EXISTS object_history; + +CREATE TABLE object_history ( +  oh_id INTEGER PRIMARY KEY NOT NULL, +  oh_type varchar(16) NOT NULL, +  oh_name varchar(255) NOT NULL, +  oh_action varchar(16) NOT NULL, +  oh_field varchar(16), +  oh_type_field varchar(255), +  oh_old varchar(255), +  oh_new varchar(255), +  oh_by varchar(255) NOT NULL, +  oh_from varchar(255) NOT NULL, +  oh_on datetime NOT NULL, +  FOREIGN KEY (oh_type, oh_name) REFERENCES objects(ob_type, ob_name) +); + +CREATE INDEX object_history_idx_oh_type_oh_name ON object_history (oh_type, oh_name); + +COMMIT; diff --git a/perl/t/data/duo/integration.json b/perl/t/data/duo/integration.json new file mode 100644 index 0000000..6e569d6 --- /dev/null +++ b/perl/t/data/duo/integration.json @@ -0,0 +1,11 @@ +{ +    "enroll_policy": "enroll", +    "greeting": "", +    "groups_allowed": [], +    "integration_key": "DIRWIH0ZZPV4G88B37VQ", +    "name": "Integration for UNIX PAM", +    "notes": "", +    "secret_key": "QO4ZLqQVRIOZYkHfdPDORfcNf8LeXIbCWwHazY7o", +    "type": "unix", +    "visual_style": "default" +} diff --git a/perl/t/data/duo/keys.json b/perl/t/data/duo/keys.json new file mode 100644 index 0000000..0de11ff --- /dev/null +++ b/perl/t/data/duo/keys.json @@ -0,0 +1,5 @@ +{ +    "integration_key": "VWFQIFMA9E79ZFG0ABIQ", +    "secret_key": "BAbja87NB8AmzlgalGAm09abNqpGZVva985al1zF", +    "api_hostname": "example-admin.duosecurity.com" +} diff --git a/perl/t/duo.t b/perl/t/duo.t new file mode 100755 index 0000000..12fee3a --- /dev/null +++ b/perl/t/duo.t @@ -0,0 +1,157 @@ +#!/usr/bin/perl +# +# Tests for the Duo integration object implementation. +# +# Written by Russ Allbery <eagle@eyrie.org> +# Copyright 2014 +#     The Board of Trustees of the Leland Stanford Junior University +# +# See LICENSE for licensing terms. + +use strict; +use warnings; + +use POSIX qw(strftime); +use Test::More; + +BEGIN { +    eval 'use Net::Duo'; +    plan skip_all => 'Net::Duo required for testing duo' +      if $@; +    eval 'use Net::Duo::Mock::Agent'; +    plan skip_all => 'Net::Duo::Mock::Agent required for testing duo' +      if $@; +} + +BEGIN { +    use_ok('Wallet::Admin'); +    use_ok('Wallet::Config'); +    use_ok('Wallet::Object::Duo'); +} + +use lib 't/lib'; +use Util; + +# Some global defaults to use. +my $user = 'admin@EXAMPLE.COM'; +my $host = 'localhost'; +my @trace = ($user, $host, time); +my $date = strftime ('%Y-%m-%d %H:%M:%S', localtime $trace[2]); + +# Flush all output immediately. +$| = 1; + +# Use Wallet::Admin to set up the database. +db_setup; +my $admin = eval { Wallet::Admin->new }; +is ($@, '', 'Database connection succeeded'); +is ($admin->reinitialize ($user), 1, 'Database initialization succeeded'); +my $schema = $admin->schema; + +# Create a mock object to use for Duo calls. +my $mock = Net::Duo::Mock::Agent->new ({ key_file => 't/data/duo/keys.json' }); + +# Test error handling in the absence of configuration. +my $object = eval { +    Wallet::Object::Duo->new ('duo', 'test', $schema); +}; +is ($object, undef, 'Wallet::Object::Duo new with no config failed'); +is ($@, "duo object implementation not configured\n", '...with correct error'); +$object = eval { +    Wallet::Object::Duo->create ('duo', 'test', $schema, @trace); +}; +is ($object, undef, 'Wallet::Object::Duo creation with no config failed'); +is ($@, "duo object implementation not configured\n", '...with correct error'); + +# Set up the Duo configuration. +$Wallet::Config::DUO_AGENT    = $mock; +$Wallet::Config::DUO_KEY_FILE = 't/data/duo/keys.json'; + +# Test creating an integration. +note ('Test creating an integration'); +my $expected = { +    name  => 'test', +    notes => 'Managed by wallet', +    type  => 'unix', +}; +$mock->expect ( +    { +        method        => 'POST', +        uri           => '/admin/v1/integrations', +        content       => $expected, +        response_file => 't/data/duo/integration.json', +    } +); +$object = Wallet::Object::Duo->create ('duo', 'test', $schema, @trace); +isa_ok ($object, 'Wallet::Object::Duo'); + +# Check the metadata about the new wallet object. +$expected = <<"EOO"; +           Type: duo +           Name: test +        Duo key: DIRWIH0ZZPV4G88B37VQ +     Created by: $user +   Created from: $host +     Created on: $date +EOO +is ($object->show, $expected, 'Show output is correct'); + +# Test retrieving the integration information. +note ('Test retrieving an integration'); +$mock->expect ( +    { +        method        => 'GET', +        uri           => '/admin/v1/integrations/DIRWIH0ZZPV4G88B37VQ', +        response_file => 't/data/duo/integration.json', +    } +); +my $data = $object->get (@trace); +ok (defined ($data), 'Retrieval succeeds'); +$expected = <<'EOO'; +[duo] +ikey = DIRWIH0ZZPV4G88B37VQ +skey = QO4ZLqQVRIOZYkHfdPDORfcNf8LeXIbCWwHazY7o +host = example-admin.duosecurity.com +EOO +is ($data, $expected, '...and integration data is correct'); + +# Ensure that we can't retrieve the object when locked. +is ($object->flag_set ('locked', @trace), 1, +    'Setting object to locked succeeds'); +is ($object->get, undef, '...and now get fails'); +is ($object->error, 'cannot get duo:test: object is locked', +    '...with correct error'); +is ($object->flag_clear ('locked', @trace), 1, +    '...and clearing locked flag works'); + +# Create a new object by wallet type and name. +$object = Wallet::Object::Duo->new ('duo', 'test', $schema); + +# Test deleting an integration.  We can't test this entirely properly because +# currently Net::Duo::Mock::Agent doesn't support stacking multiple expected +# calls and delete makes two calls. +note ('Test deleting an integration'); +$mock->expect ( +    { +        method        => 'GET', +        uri           => '/admin/v1/integrations/DIRWIH0ZZPV4G88B37VQ', +        response_file => 't/data/duo/integration.json', +    } +); +TODO: { +    local $TODO = 'Net::Duo::Mock::Agent not yet capable'; + +    is ($object->destroy (@trace), 1, 'Duo object deletion succeeded'); +    $object = eval { Wallet::Object::Duo->new ('duo', 'test', $schema) }; +    is ($object, undef, '...and now object cannot be retrieved'); +    is ($@, "cannot find duo:test\n", '...with correct error'); +} + +# Clean up. +$admin->destroy; +undef $admin; +undef $object; +unlink ('wallet-db'); + +# Done testing. +done_testing (); | 
