Concierge

 view release on metacpan or  search on metacpan

lib/Concierge.pm  view on Meta::CPAN

package Concierge v0.8.4;
use v5.36;

our $VERSION = 'v0.8.4';

# ABSTRACT: Service layer orchestrator for authentication, sessions, and user data

use Carp qw<carp croak>;
use JSON::PP qw< encode_json decode_json >;
use File::Spec;
use Params::Filter qw< make_filter >;

# === COMPONENT MODULES ===
use Concierge::Auth;
use Concierge::Sessions;
use Concierge::Users;
use Concierge::Desk::User;

# === PARAMETER FILTERS ===
# Shared filters for secure data segregation

# Auth filter - ONLY credentials (user_id + password)
our $auth_data_filter = make_filter(
    [qw(user_id password)],                   # required credentials
    [],                                        # accepted - nothing else
    [],                                        # excluded - not needed
);

# User data filter - everything EXCEPT credentials
# Handles both minimal input (user_id, moniker) and
# rich input (user_id, moniker, email, phone, bio, etc.)
our $user_data_filter = make_filter(
    [qw(user_id moniker)],                    # required minimum
    ['*'],                                    # accept ALL other fields, except:
    [qw(password confirm_password)],          # excluded - security boundary
);

# Session data filter - for populating session with initial data
# Accepts user_id (required for new_session) plus any session fields
# Excludes credentials (never stored in session data)
our $session_data_filter = make_filter(
    [qw(user_id)],                            # required for new_session
    ['*'],                                    # accept all other fields, except:
    [qw(password confirm_password)],          # excluded - security boundary
);

# User update filter - for updating existing user records
# No required fields (user_id passed separately as parameter)
# Excludes user_id (identity field), password (use reset_password instead)
our $user_update_filter = make_filter(
    [],                                       # no required fields
    ['*'],                                    # accept all fields, except:
    [qw(user_id password confirm_password)],  # excluded - never in updates
);

sub new_concierge {
    my ($class) = @_;
	bless {}, $class;
}

# =============================================================================
# DESK MANAGEMENT - Opening existing desks
# =============================================================================

sub open_desk ($class, $desk_location) {
	croak "Desk not found: *$desk_location*" unless -d $desk_location;
	my $concierge	= Concierge->new_concierge(); 	# minimal object
	$concierge->{desk_location} = $desk_location;

	# Instantiate the concierge from config stored in Concierge's config file
    my $concierge_conf_file	= File::Spec->catfile($desk_location, 'concierge.conf');

    # Read entire file (pretty JSON spans multiple lines)
    my ($fh,$json,$concierge_config);
    {
    	local $/;
    	open $fh, "<", $concierge_conf_file
    		and
    	$json = <$fh>
    		and
    	close $fh
    		or return { success => 0, message => "Error closing session file: $!" };
    }
    unless (defined $json) {
        return { success => 0, message => "Config file is empty" };
    }
    eval {
        $concierge_config = decode_json($json);
    };
    if ($@) {
        return { success => 0, message => "Invalid JSON in config file: $@" };
    }

	# Instantiate sessions manager from $concierge_config
	$concierge->{sessions}	= Concierge::Sessions->new(
		storage_dir => $concierge_config->{sessions_dir} || $concierge_config->{storage_dir},
		backend		=> $concierge_config->{sessions_backend} || '',
	);

	# Load user_keys mapping from file (or initialize empty for new desk)

lib/Concierge.pm  view on Meta::CPAN

sub restore_user ($self, $user_key) {
    return { success => 0, message => 'user_key is required' }
        unless defined $user_key && length($user_key);

    # Step 1: Lookup user_key in mapping
    my $mapping = $self->{user_keys}{$user_key};
    return { success => 0, message => 'user_key not found' }
        unless $mapping;

    my $user_id    = $mapping->{user_id};
    my $session_id = $mapping->{session_id};

    # Step 2: Retrieve session (validates it still exists and hasn't expired)
    my $session_result = $self->sessions->get_session($session_id);
    unless ($session_result->{success}) {
        # Session gone or expired -- clean up stale mapping
        delete $self->{user_keys}{$user_key};
        $self->save_user_keys();
        return { success => 0, message => 'Session expired' };
    }

    my $session = $session_result->{session};

    # Step 3: Determine user type -- logged-in or guest
    my $user_result = $self->users->get_user($user_id);

    if ($user_result->{success}) {
        # Logged-in user: rebuild with user data and backend closures
        my ($get, $update) = $self->_make_user_closures($user_id);
        my $user = Concierge::Desk::User->enable_user($user_id, {
            session           => $session,
            user_data         => $user_result->{user},
            user_key          => $user_key,
            _get_user_data    => $get,
            _update_user_data => $update,
        });

        return {
            success => 1,
            message => 'User restored',
            user    => $user,
        };
    }
    else {
        # Guest: session only, no user data
        my $user = Concierge::Desk::User->enable_user($user_id, {
            session  => $session,
            user_key => $user_key,
        });

        return {
            success  => 1,
            message  => 'Guest restored',
            user     => $user,
            is_guest => 1,
        };
    }
}

# Login user: authenticate, create session, assign user_key and store external_key mapping
sub login_user ($self, $credentials, $session_opts={}) {
    # Step 0: Get credentials
    my $auth_data = $auth_data_filter->($credentials);
    return { success => 0, message => 'Missing user_id or password' }
        unless $auth_data;

    my $user_id = $auth_data->{user_id};
    my $password = $auth_data->{password};

    # Step 1: Get user from database
    my $user_result = $self->users->get_user($user_id);
    return { success => 0, message => 'User not found' }
        unless $user_result->{success};

    # Step 2: Authenticate with ID & password
    my ($auth_ok, $auth_msg) = $self->auth->checkPwd($user_id, $password);
    return { success => 0, message => $auth_msg || 'Authentication failed' }
        unless $auth_ok;

    # Step 3: Create session
    my $session_result = $self->sessions->new_session(
        user_id         => $user_id,
        %{ $session_opts || {} },
    );
    return { success => 0, message => $session_result->{message} || 'Failed to create session' }
        unless $session_result->{success};

    my $session = $session_result->{session};
    my $session_id = $session->session_id();

    # Create user object for logged-in user
    my ($get, $update) = $self->_make_user_closures($user_id);
    my $user = Concierge::Desk::User->enable_user($user_id, {
        session           => $session,
        user_data         => $user_result->{user},
        _get_user_data    => $get,
        _update_user_data => $update,
    });

    # Store user_key mapping
    $self->{user_keys}{$user->user_key()} = {
        user_id    => $user_id,
        session_id => $session_id,
    };
    $self->save_user_keys();

    return {
        success => 1,
        message => 'Login successful',
        user    => $user,
    };
}

# Verify password: check if password is correct for user
sub verify_password ($self, $user_id, $password) {
    # Verifies if provided password is correct for the user
    # Usually not needed - if user has valid session/user_key, they're already authenticated
    # Use cases: sensitive operations requiring re-authentication, admin verification, etc.
    # Most password resets don't need this - session authentication is sufficient

    # Minimal validation - application controls when this is called
    return { success => 0, message => 'user_id is required' }
        unless defined $user_id && length($user_id);

lib/Concierge.pm  view on Meta::CPAN


=head1 SYNOPSIS

    use Concierge;

    # Open an existing desk (created by Concierge::Desk::Setup)
    my $desk = Concierge->open_desk('./desk');
    my $concierge = $desk->{concierge};

    # Register a user
    $concierge->add_user({
        user_id  => 'alice',
        moniker  => 'Alice',
        email    => 'alice@example.com',
        password => 'secret123',
    });

    # Log in -- returns a Concierge::Desk::User object
    my $login = $concierge->login_user({
        user_id  => 'alice',
        password => 'secret123',
    });
    my $user = $login->{user};

    # User object provides direct access
    say $user->moniker;         # "Alice"
    say $user->session_id;      # random hex string
    say $user->is_logged_in;    # 1

    # Restore user from a cookie on next request
    my $restore = $concierge->restore_user($user->user_key);
    my $same_user = $restore->{user};

    # Log out
    $concierge->logout_user($user->session_id);

=head1 DESCRIPTION

Concierge coordinates three component modules behind a single API:

=over 4

=item * B<Concierge::Auth> -- password authentication (Argon2)

=item * B<Concierge::Sessions> -- session management (SQLite or file backends)

=item * B<Concierge::Users> -- user data storage (SQLite, YAML, or CSV/TSV backends)

=back

Applications interact only with Concierge and the L<Concierge::Desk::User> objects
it returns. The component modules are never exposed directly.

=head2 What the Suite Provides

Concierge handles orchestration -- coordinating components, managing the
user_key mapping, and returning consistent structured results. The
capabilities of the suite live in the three components:

B<Authentication> (L<Concierge::Auth>): Argon2id password hashing and
verification; no plaintext credentials are ever written to disk. Also
provides random token, UUID, word-passphrase, and hex-ID generators. The
component is substitutable: any replacement implementing the same method
contract (C<checkPwd>, C<setPwd>, C<resetPwd>, etc.) can replace it for
LDAP, OAuth, or any other scheme.

B<Sessions> (L<Concierge::Sessions>): Full session lifecycle -- creation,
retrieval, expiry, and cleanup -- with SQLite, file, or in-memory backends.
Sessions carry arbitrary key/value data. A single-session-per-user policy
is enforced: creating a new session automatically removes any prior session
for that user. Expired sessions are cleaned up each time a desk is opened.

B<User Records> (L<Concierge::Users>): User data store with a configurable
field schema. Standard fields (C<moniker>, C<email>, C<phone>,
C<access_level>, C<user_status>, C<term_ends>, and others) are built in and
can be selectively overridden. Applications add their own fields via
C<app_fields> at setup time. Supports SQLite, YAML, and CSV/TSV backends,
with filtering and listing operations.

For the full API of any component, see its own documentation.

=head2 Desks

A I<desk> is a storage directory containing the configuration and data files
for all three components. Use L<Concierge::Desk::Setup> to create a desk, then
C<open_desk()> to load it at runtime.

=head2 User Participation Levels

Concierge provides three graduated levels of user participation, each
returning a L<Concierge::Desk::User> object:

=over 4

=item B<Visitor> -- C<admit_visitor()>

Assigned a unique identifier only. No session, no stored data. Suitable for
anonymous tracking (e.g., cookies).

=item B<Guest> -- C<checkin_guest()>

Assigned an identifier and a session. Can store temporary data (e.g., a
shopping cart). No authentication or persistent user record.

=item B<Logged-in user> -- C<login_user()>

Authenticated with credentials. Has a session, persistent user data, and
full access to the User object's data methods.

=back

A guest can be converted to a logged-in user with C<login_guest()>,
transferring any session data accumulated during the guest session.

=head2 User Keys

Each active user (guest or logged-in) is tracked by a I<user_key> -- a
random token stored in the concierge's C<user_keys> mapping alongside the
user's C<user_id> and C<session_id>. This mapping is persisted to
C<user_keys.json> in the desk directory and synchronized against active
sessions when the desk is opened.

=head2 Return Values

All methods return a hashref with at least C<success> (0 or 1) and
C<message>:

    # Success
    { success => 1, message => '...', ... }

    # Failure
    { success => 0, message => 'error description' }

Success responses include additional fields relevant to the operation:

=over 4

=item *

User lifecycle methods (C<login_user()>, C<restore_user()>, C<checkin_guest()>,
C<admit_visitor()>, C<login_guest()>) return C<user>, a L<Concierge::Desk::User>
object. Guest and visitor results also set C<is_guest> or C<is_visitor> to 1.

=item *

C<open_desk()> returns C<concierge>, the ready-to-use Concierge object.

=item *

User management methods return C<user_id>. C<remove_user()> also returns
C<deleted_from> (arrayref of component names) and, if any deletion failed,
C<warnings> (arrayref).

=item *

C<verify_user()> returns C<verified> (0 or 1), C<exists_in_auth>, and
C<exists_in_users>.

=item *

C<list_users()> returns C<user_ids> (arrayref) and C<count>. With
C<< include_data => 1 >>, also returns C<users> (hashref keyed by user_id).

=back

See the individual method descriptions below for the complete field list.

lib/Concierge.pm  view on Meta::CPAN


=item L<Concierge::Sessions> -- session lifecycle and persistence

=item L<Concierge::Users> -- user records with configurable field schemas

=back

These three are tightly orchestrated: a single C<login_user()> call
authenticates via Auth, retrieves a record from Users, and creates a
session through Sessions.  This coordination is the purpose of
Concierge -- applications interact with the Concierge API and the
L<Concierge::Desk::User> objects it returns, not with the components
directly.

The identity core is designed to be sufficient on its own, but the
component pattern it follows -- backend abstraction, setup-time
configuration, and Concierge-level orchestration -- is intentionally
replicable.  Each identity core component can also be substituted with
a conforming replacement, and additional components (Organizations,
Assets, etc.) can be added by following the same conventions.  See
L</EXTENSIBILITY> for details.

=head1 METHODS

=head2 Desk Management

=head3 open_desk

    my $result = Concierge->open_desk($desk_location);
    my $concierge = $result->{concierge};

Opens an existing desk directory created by L<Concierge::Desk::Setup>. Reads
the configuration file, instantiates all component modules, loads the
user_keys mapping, and runs session cleanup.

Croaks if C<$desk_location> is not an existing directory.

Returns C<< { success => 1, concierge => $obj } >> on success.

=head2 User Lifecycle

=head3 admit_visitor

    my $result = $concierge->admit_visitor();
    my $user = $result->{user};    # Concierge::Desk::User (visitor)

Creates a visitor with a generated identifier. No session is created
and no data is stored.

=head3 checkin_guest

    my $result = $concierge->checkin_guest(\%session_opts);
    my $user = $result->{user};    # Concierge::Desk::User (guest)

Creates a guest with a generated identifier and a session. The optional
C<%session_opts> hashref may include C<timeout> (in seconds; defaults to
1800).

=head3 login_user

    my $result = $concierge->login_user(\%credentials, \%session_opts);
    my $user = $result->{user};    # Concierge::Desk::User (logged-in)

Authenticates C<user_id> and C<password> from C<%credentials>, retrieves
the user's data record, creates a session, and returns a fully-equipped
User object. If the user already has an active session, the previous
session is replaced.

=head3 restore_user

    my $result = $concierge->restore_user($user_key);
    my $user = $result->{user};    # Concierge::Desk::User (guest or logged-in)

Reconstructs a User object from a C<user_key> (typically stored in a cookie
or URL token). Looks up the key in the concierge mapping, validates the
session, and determines whether the user is a guest or logged-in user.

Logged-in users are restored with their full user data snapshot and backend
closures. Guests are restored with their session only.

If the session has expired, the stale mapping entry is cleaned up and the
method returns failure. The application can then redirect to login or create
a new guest as appropriate.

Returns C<< { success => 1, user => $user } >> on success. Guest restores
also include C<< is_guest => 1 >>.

=head3 login_guest

    my $result = $concierge->login_guest(\%credentials, $guest_user_key);
    my $user = $result->{user};    # Concierge::Desk::User (logged-in)

Converts a guest to a logged-in user. Authenticates with C<%credentials>,
transfers any data from the guest's session to the new session, then
deletes the guest session and removes the guest's user_key mapping.

=head3 logout_user

    my $result = $concierge->logout_user($session_id);

Deletes the session and removes the user_key mapping entry.

=head2 Admin Operations

=head3 add_user

    my $result = $concierge->add_user(\%user_input);

Registers a new user. C<%user_input> must include C<user_id>, C<moniker>,
and C<password>. Any additional fields (C<email>, C<phone>, application-
defined fields, etc.) are stored in the Users component. The password is
stored separately in the Auth component and never reaches the user data
store.

If password validation fails, the Users record is rolled back.

=head3 remove_user

    my $result = $concierge->remove_user($user_id);

Removes the user from all components: Users, Auth, Sessions, and the
user_keys mapping. Attempts all deletions; the response includes
C<deleted_from> (arrayref) and C<warnings> (arrayref, if any component
deletion failed).

=head3 verify_user

    my $result = $concierge->verify_user($user_id);

Checks whether C<$user_id> exists in both Auth and Users components.
Returns C<< verified => 1 >> only if present in both. Includes
C<exists_in_auth> and C<exists_in_users> flags, and a C<warning> if
the user exists in one component but not the other.

=head3 list_users

    # IDs only
    my $result = $concierge->list_users($filter, \%options);
    my @ids = @{ $result->{user_ids} };

    # With full data
    my $result = $concierge->list_users('', { include_data => 1 });
    my %users = %{ $result->{users} };

Returns user IDs from the Users component. C<$filter> is a string passed
through to L<Concierge::Users>. With C<< include_data => 1 >>, fetches
each user's full record into a C<users> hash keyed by user_id. With
C<< fields => [...] >>, returns only the specified fields per user.

=head3 get_user_data

    my $result = $concierge->get_user_data($user_id, @fields);
    my $data = $result->{user};

Retrieves user data from the Users component. If C<@fields> is provided,
returns only those fields; otherwise returns all fields.

=head3 update_user_data

    my $result = $concierge->update_user_data($user_id, \%updates);

Updates the user's record in the Users component. The C<user_id> and
C<password> fields are filtered out and cannot be changed through this
method.

=head2 Password Operations

Initial password registration is handled by C<add_user()>, which sets the
password atomically with user creation. The methods here operate on passwords
for existing users.

=head3 verify_password

    my $result = $concierge->verify_password($user_id, $password);

Checks whether C<$password> is correct for C<$user_id>. Returns
C<< success => 1 >> if the password matches.

=head3 reset_password

    my $result = $concierge->reset_password($user_id, $new_password);

Sets a new password for an existing user. The application is responsible
for verifying the user's identity before calling this method.

=head1 PARAMETER FILTERS

Concierge uses L<Params::Filter> to enforce data segregation at method
boundaries:

=over 4

=item C<$auth_data_filter> -- extracts only C<user_id> and C<password>

=item C<$user_data_filter> -- extracts everything except C<password>

=item C<$session_data_filter> -- extracts C<user_id> plus non-credential fields

=item C<$user_update_filter> -- excludes C<user_id> and C<password> from updates

=back

These ensure that credentials never leak into user data stores and that
identity fields cannot be changed via update operations.

=head1 EXTENSIBILITY

=head2 Component Substitution

Each identity core component can be replaced with a drop-in alternative as
long as the replacement implements the methods Concierge calls on it.

B<Auth> -- Concierge calls:

=over 4

=item C<< Concierge::Auth->new(\%args) >> -- constructor; accepts C<file> key

=item C<< $auth->checkID($user_id) >> -- returns true/false

=item C<< $auth->checkPwd($user_id, $password) >> -- returns C<($ok, $message)>

=item C<< $auth->setPwd($user_id, $password) >> -- returns C<($ok, $message)>

=item C<< $auth->resetPwd($user_id, $new_password) >> -- returns C<($ok, $message)>

=item C<< $auth->deleteID($user_id) >> -- returns C<($ok, $message)>

=item C<< Concierge::Auth->gen_random_string($length) >> -- class method, returns string

=back

B<Sessions> -- Concierge calls:

=over 4

=item C<< Concierge::Sessions->new(%args) >> -- constructor; accepts C<storage_dir> and C<backend>

=item C<< $sessions->new_session(%args) >> -- returns C<< { success => 1, session => $obj } >>

=item C<< $sessions->get_session($session_id) >> -- returns C<< { success => 1, session => $obj } >>

=item C<< $sessions->delete_session($session_id) >> -- returns C<< { success => 1|0, ... } >>

=item C<< $sessions->cleanup_sessions() >> -- returns C<< { success => 1, deleted_count => N, active => [...] } >>

=back

B<Users> -- Concierge calls:

=over 4

=item C<< Concierge::Users->new($config_file) >> -- constructor

=item C<< $users->register_user(\%data) >> -- returns C<< { success => 1|0, message => '...' } >>

=item C<< $users->get_user($user_id) >> -- returns C<< { success => 1, user => \%data } >>

=item C<< $users->update_user($user_id, \%updates) >> -- returns C<< { success => 1|0, ... } >>

=item C<< $users->delete_user($user_id) >> -- returns C<< { success => 1|0, ... } >>

=item C<< $users->list_users($filter) >> -- returns C<< { success => 1, user_ids => [...] } >>



( run in 1.028 second using v1.01-cache-2.11-cpan-22024b96cdf )