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 )