commit 305c79e03f659b96d9b8fb1f4316218d1271be28
Author: Mikhail Gribkov <m.gribkov@postgrespro.ru>
Commit: Mikhail Gribkov <m.gribkov@postgrespro.ru>

    [PATCH v39] Add support of event triggers on authenticated login
    
    The patch introduces trigger on login event, allowing to fire some actions
    right on the user connection. This can be useful for  logging or connection
    check purposes as well as for some personalization of environment. Usage
    details are described in the documentation included, but shortly usage is
    the same as for other triggers: create function returning event_trigger and
    then create event trigger on login event.

diff --git a/doc/src/sgml/event-trigger.sgml b/doc/src/sgml/event-trigger.sgml
index 3b6a5361b3..94d28835d1 100644
--- a/doc/src/sgml/event-trigger.sgml
+++ b/doc/src/sgml/event-trigger.sgml
@@ -28,6 +28,7 @@
      An event trigger fires whenever the event with which it is associated
      occurs in the database in which it is defined. Currently, the only
      supported events are
+     <literal>login</literal>,
      <literal>ddl_command_start</literal>,
      <literal>ddl_command_end</literal>,
      <literal>table_rewrite</literal>
@@ -35,6 +36,18 @@
      Support for additional events may be added in future releases.
    </para>
 
+   <para>
+     The <literal>login</literal> event occurs when an authenticated user logs
+     into the system. Any bug in a trigger procedure for this event may
+     prevent successful login to the system. Such bugs may be fixed by
+     restarting the system in single-user mode (as event triggers are
+     disabled in this mode). See the <xref linkend="app-postgres"/> reference
+     page for details about using single-user mode.
+     The <literal>login</literal> event will also fire on standby servers.
+     To prevent servers from becoming inaccessible, such triggers must avoid
+     writing anything to the database when running on a standby.
+   </para>
+
    <para>
      The <literal>ddl_command_start</literal> event occurs just before the
      execution of a <literal>CREATE</literal>, <literal>ALTER</literal>, <literal>DROP</literal>,
@@ -1300,4 +1313,79 @@ CREATE EVENT TRIGGER no_rewrite_allowed
 </programlisting>
    </para>
  </sect1>
+
+  <sect1 id="event-trigger-database-login-example">
+    <title>A Database Login Event Trigger Example</title>
+
+    <para>
+      The event trigger on the <literal>login</literal> event can be
+      useful for logging user logins, for verifying the connection and
+      assigning roles according to current circumstances, or for session
+      data initialization. It is very important that any event trigger using
+      the <literal>login</literal> event checks whether or not the database is
+      in recovery before performing any writes. Writing to a standby server
+      will make it inaccessible.
+    </para>
+
+    <para>
+      The following example demonstrates these options.
+<programlisting>
+-- create test tables and roles
+CREATE TABLE user_login_log (
+  "user" text,
+  "session_start" timestamp with time zone
+);
+CREATE ROLE day_worker;
+CREATE ROLE night_worker;
+
+-- the example trigger function
+CREATE OR REPLACE FUNCTION init_session()
+  RETURNS event_trigger SECURITY DEFINER
+  LANGUAGE plpgsql AS
+$$
+DECLARE
+  hour integer = EXTRACT('hour' FROM current_time at time zone 'utc');
+  rec boolean;
+BEGIN
+-- 1. Forbid logging in between 2AM and 4AM.
+IF hour BETWEEN 2 AND 4 THEN
+  RAISE EXCEPTION 'Login forbidden';
+END IF;
+
+-- The checks below cannot be performed on standby servers so
+-- ensure the database is not in recovery before we perform any
+-- operations.
+SELECT pg_is_in_recovery() INTO rec;
+IF rec THEN
+  RETURN;
+END IF;
+
+-- 2. Assign some roles. At daytime, grant the day_worker role, else the
+-- night_worker role.
+IF hour BETWEEN 8 AND 20 THEN
+  EXECUTE 'REVOKE night_worker FROM ' || quote_ident(session_user);
+  EXECUTE 'GRANT day_worker TO ' || quote_ident(session_user);
+ELSE
+  EXECUTE 'REVOKE day_worker FROM ' || quote_ident(session_user);
+  EXECUTE 'GRANT night_worker TO ' || quote_ident(session_user);
+END IF;
+
+-- 3. Initialize user session data
+CREATE TEMP TABLE session_storage (x float, y integer);
+ALTER TABLE session_storage OWNER TO session_user;
+
+-- 4. Log the connection time
+INSERT INTO public.user_login_log VALUES (session_user, current_timestamp);
+
+END;
+$$;
+
+-- trigger definition
+CREATE EVENT TRIGGER init_session
+  ON login
+  EXECUTE FUNCTION init_session();
+ALTER EVENT TRIGGER init_session ENABLE ALWAYS;
+</programlisting>
+    </para>
+  </sect1>
 </chapter>
diff --git a/src/backend/commands/event_trigger.c b/src/backend/commands/event_trigger.c
index d4b00d1a82..c83c5d78dd 100644
--- a/src/backend/commands/event_trigger.c
+++ b/src/backend/commands/event_trigger.c
@@ -20,6 +20,7 @@
 #include "catalog/dependency.h"
 #include "catalog/indexing.h"
 #include "catalog/objectaccess.h"
+#include "catalog/pg_database.h"
 #include "catalog/pg_event_trigger.h"
 #include "catalog/pg_namespace.h"
 #include "catalog/pg_opclass.h"
@@ -37,15 +38,18 @@
 #include "miscadmin.h"
 #include "parser/parse_func.h"
 #include "pgstat.h"
+#include "storage/lmgr.h"
 #include "tcop/deparse_utility.h"
 #include "tcop/utility.h"
 #include "utils/acl.h"
 #include "utils/builtins.h"
 #include "utils/evtcache.h"
 #include "utils/fmgroids.h"
+#include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/rel.h"
+#include "utils/snapmgr.h"
 #include "utils/syscache.h"
 
 typedef struct EventTriggerQueryState
@@ -130,6 +134,7 @@ CreateEventTrigger(CreateEventTrigStmt *stmt)
 	if (strcmp(stmt->eventname, "ddl_command_start") != 0 &&
 		strcmp(stmt->eventname, "ddl_command_end") != 0 &&
 		strcmp(stmt->eventname, "sql_drop") != 0 &&
+		strcmp(stmt->eventname, "login") != 0 &&
 		strcmp(stmt->eventname, "table_rewrite") != 0)
 		ereport(ERROR,
 				(errcode(ERRCODE_SYNTAX_ERROR),
@@ -579,10 +584,15 @@ EventTriggerCommonSetup(Node *parsetree,
 	{
 		CommandTag	dbgtag;
 
-		dbgtag = CreateCommandTag(parsetree);
+		if (event == EVT_Login)
+			dbgtag = CMDTAG_LOGIN;
+		else
+			dbgtag = CreateCommandTag(parsetree);
+
 		if (event == EVT_DDLCommandStart ||
 			event == EVT_DDLCommandEnd ||
-			event == EVT_SQLDrop)
+			event == EVT_SQLDrop ||
+			event == EVT_Login)
 		{
 			if (!command_tag_event_trigger_ok(dbgtag))
 				elog(ERROR, "unexpected command tag \"%s\"", GetCommandTagName(dbgtag));
@@ -601,7 +611,10 @@ EventTriggerCommonSetup(Node *parsetree,
 		return NIL;
 
 	/* Get the command tag. */
-	tag = CreateCommandTag(parsetree);
+	if (event == EVT_Login)
+		tag = CMDTAG_LOGIN;
+	else
+		tag = CreateCommandTag(parsetree);
 
 	/*
 	 * Filter list of event triggers by command tag, and copy them into our
@@ -799,6 +812,46 @@ EventTriggerSQLDrop(Node *parsetree)
 	list_free(runlist);
 }
 
+/*
+ * Fire login event triggers if any are present.
+ */
+void
+EventTriggerOnLogin(void)
+{
+	List	   *runlist;
+	EventTriggerData trigdata;
+
+	/*
+	 * See EventTriggerDDLCommandStart for a discussion about why event
+	 * triggers are disabled in single user mode.
+	 */
+	if (!IsUnderPostmaster || !OidIsValid(MyDatabaseId))
+		return;
+
+	StartTransactionCommand();
+
+	/* Find login triggers and run what was found */
+	runlist = EventTriggerCommonSetup(NULL,
+										EVT_Login, "login",
+										&trigdata);
+
+	if (runlist != NIL)
+	{
+		/* Event trigger execution may require an active snapshot. */
+		PushActiveSnapshot(GetTransactionSnapshot());
+
+		/* Run the triggers. */
+		EventTriggerInvoke(runlist, &trigdata);
+
+		/* Cleanup. */
+		list_free(runlist);
+
+		PopActiveSnapshot();
+	}
+
+	CommitTransactionCommand();
+}
+
 
 /*
  * Fire table_rewrite triggers.
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index cab709b07b..33a4dfeaa2 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -32,6 +32,7 @@
 #include "access/xact.h"
 #include "catalog/pg_type.h"
 #include "commands/async.h"
+#include "commands/event_trigger.h"
 #include "commands/prepare.h"
 #include "common/pg_prng.h"
 #include "jit/jit.h"
@@ -4226,6 +4227,9 @@ PostgresMain(const char *dbname, const char *username)
 	initStringInfo(&row_description_buf);
 	MemoryContextSwitchTo(TopMemoryContext);
 
+	/* Fire any defined login event triggers, if appropriate */
+	EventTriggerOnLogin();
+
 	/*
 	 * POSTGRES main processing loop begins here
 	 *
diff --git a/src/backend/utils/cache/evtcache.c b/src/backend/utils/cache/evtcache.c
index 1f5e7eb4c6..9b598276f4 100644
--- a/src/backend/utils/cache/evtcache.c
+++ b/src/backend/utils/cache/evtcache.c
@@ -167,6 +167,8 @@ BuildEventTriggerCache(void)
 			event = EVT_SQLDrop;
 		else if (strcmp(evtevent, "table_rewrite") == 0)
 			event = EVT_TableRewrite;
+		else if (strcmp(evtevent, "login") == 0)
+			event = EVT_Login;
 		else
 			continue;
 
diff --git a/src/bin/pg_dump/t/002_pg_dump.pl b/src/bin/pg_dump/t/002_pg_dump.pl
index a22f27f300..a69ad73bae 100644
--- a/src/bin/pg_dump/t/002_pg_dump.pl
+++ b/src/bin/pg_dump/t/002_pg_dump.pl
@@ -2015,6 +2015,25 @@ my %tests = (
 		},
 	},
 
+	'CREATE FUNCTION dump_test.login_event_trigger_func' => {
+		create_order => 32,
+		create_sql   => 'CREATE FUNCTION dump_test.login_event_trigger_func()
+					   RETURNS event_trigger LANGUAGE plpgsql
+					   AS $$ BEGIN RETURN; END;$$;',
+		regexp => qr/^
+			\QCREATE FUNCTION dump_test.login_event_trigger_func() RETURNS event_trigger\E
+			\n\s+\QLANGUAGE plpgsql\E
+			\n\s+AS\ \$\$
+			\Q BEGIN RETURN; END;\E
+			\$\$;/xm,
+		like =>
+		  { %full_runs, %dump_test_schema_runs, section_pre_data => 1, },
+		unlike => {
+			exclude_dump_test_schema => 1,
+			only_dump_measurement    => 1,
+		},
+	},
+
 	'CREATE OPERATOR FAMILY dump_test.op_family' => {
 		create_order => 73,
 		create_sql =>
@@ -2127,6 +2146,19 @@ my %tests = (
 		like => { %full_runs, section_post_data => 1, },
 	},
 
+	'CREATE EVENT TRIGGER test_login_event_trigger' => {
+		create_order => 33,
+		create_sql   => 'CREATE EVENT TRIGGER test_login_event_trigger
+					   ON login
+					   EXECUTE FUNCTION dump_test.login_event_trigger_func();',
+		regexp => qr/^
+			\QCREATE EVENT TRIGGER test_login_event_trigger \E
+			\QON login\E
+			\n\s+\QEXECUTE FUNCTION dump_test.login_event_trigger_func();\E
+			/xm,
+		like => { %full_runs, section_post_data => 1, },
+	},
+
 	'CREATE TRIGGER test_trigger' => {
 		create_order => 31,
 		create_sql   => 'CREATE TRIGGER test_trigger
diff --git a/src/bin/psql/tab-complete.c b/src/bin/psql/tab-complete.c
index 42e87b9e49..fb0d98c1de 100644
--- a/src/bin/psql/tab-complete.c
+++ b/src/bin/psql/tab-complete.c
@@ -3519,8 +3519,8 @@ psql_completion(const char *text, int start, int end)
 		COMPLETE_WITH("ON");
 	/* Complete CREATE EVENT TRIGGER <name> ON with event_type */
 	else if (Matches("CREATE", "EVENT", "TRIGGER", MatchAny, "ON"))
-		COMPLETE_WITH("ddl_command_start", "ddl_command_end", "sql_drop",
-					  "table_rewrite");
+		COMPLETE_WITH("ddl_command_start", "ddl_command_end", "login",
+					  "sql_drop", "table_rewrite");
 
 	/*
 	 * Complete CREATE EVENT TRIGGER <name> ON <event_type>.  EXECUTE FUNCTION
diff --git a/src/include/commands/event_trigger.h b/src/include/commands/event_trigger.h
index 5ed6ece555..bf756d471d 100644
--- a/src/include/commands/event_trigger.h
+++ b/src/include/commands/event_trigger.h
@@ -54,6 +54,7 @@ extern void EventTriggerDDLCommandStart(Node *parsetree);
 extern void EventTriggerDDLCommandEnd(Node *parsetree);
 extern void EventTriggerSQLDrop(Node *parsetree);
 extern void EventTriggerTableRewrite(Node *parsetree, Oid tableOid, int reason);
+extern void EventTriggerOnLogin(void);
 
 extern bool EventTriggerBeginCompleteQuery(void);
 extern void EventTriggerEndCompleteQuery(void);
diff --git a/src/include/tcop/cmdtaglist.h b/src/include/tcop/cmdtaglist.h
index e738ac1c09..553a31874f 100644
--- a/src/include/tcop/cmdtaglist.h
+++ b/src/include/tcop/cmdtaglist.h
@@ -186,6 +186,7 @@ PG_CMDTAG(CMDTAG_INSERT, "INSERT", false, false, true)
 PG_CMDTAG(CMDTAG_LISTEN, "LISTEN", false, false, false)
 PG_CMDTAG(CMDTAG_LOAD, "LOAD", false, false, false)
 PG_CMDTAG(CMDTAG_LOCK_TABLE, "LOCK TABLE", false, false, false)
+PG_CMDTAG(CMDTAG_LOGIN, "LOGIN", true, false, false)
 PG_CMDTAG(CMDTAG_MERGE, "MERGE", false, false, true)
 PG_CMDTAG(CMDTAG_MOVE, "MOVE", false, false, true)
 PG_CMDTAG(CMDTAG_NOTIFY, "NOTIFY", false, false, false)
diff --git a/src/include/utils/evtcache.h b/src/include/utils/evtcache.h
index d340026518..52052e6252 100644
--- a/src/include/utils/evtcache.h
+++ b/src/include/utils/evtcache.h
@@ -22,7 +22,8 @@ typedef enum
 	EVT_DDLCommandStart,
 	EVT_DDLCommandEnd,
 	EVT_SQLDrop,
-	EVT_TableRewrite
+	EVT_TableRewrite,
+	EVT_Login,
 } EventTriggerEvent;
 
 typedef struct
diff --git a/src/test/authentication/t/005_login_trigger.pl b/src/test/authentication/t/005_login_trigger.pl
new file mode 100644
index 0000000000..fec664fb86
--- /dev/null
+++ b/src/test/authentication/t/005_login_trigger.pl
@@ -0,0 +1,157 @@
+# Copyright (c) 2021-2023, PostgreSQL Global Development Group
+
+# Tests of authentication via login trigger. Mostly for rejection via
+# exception, because this scenario cannot be covered with *.sql/*.out regress
+# tests.
+
+use strict;
+use warnings;
+
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+# Execute a psql command and compare its output towards given regexps
+sub psql_command
+{
+	local $Test::Builder::Level = $Test::Builder::Level + 1;
+	my ($node, $sql, $expected_ret, $test_name, %params) = @_;
+
+	my $connstr;
+	if (defined($params{connstr}))
+	{
+		$connstr = $params{connstr};
+	}
+	else
+	{
+		$connstr = '';
+	}
+
+	# Execute command
+	my ($ret, $stdout, $stderr) = $node->psql('postgres', $sql,
+											  connstr => "$connstr");
+
+	# Check return code
+	is($ret, $expected_ret,  "$test_name: exit code $expected_ret");
+
+	# Check stdout
+	if (defined($params{log_like}))
+	{
+		my @log_like = @{ $params{log_like} };
+		while (my $regex = shift @log_like)
+		{
+			like($stdout, $regex, "$test_name: log matches");
+		}
+	}
+	if (defined($params{log_unlike}))
+	{
+		my @log_unlike = @{ $params{log_unlike} };
+		while (my $regex = shift @log_unlike)
+		{
+			unlike($stdout, $regex, "$test_name: log unmatches");
+		}
+	}
+	if (defined($params{log_exact}))
+	{
+		is($stdout, $params{log_exact}, "$test_name: log equals");
+	}
+
+	# Check stderr
+	if (defined($params{err_like}))
+	{
+		my @err_like = @{ $params{err_like} };
+		while (my $regex = shift @err_like)
+		{
+			like($stderr, $regex, "$test_name: err matches");
+		}
+	}
+	if (defined($params{err_unlike}))
+	{
+		my @err_unlike = @{ $params{err_unlike} };
+		while (my $regex = shift @err_unlike)
+		{
+			unlike($stderr, $regex, "$test_name: err unmatches");
+		}
+	}
+	if (defined($params{err_exact}))
+	{
+		is($stderr, $params{err_exact}, "$test_name: err equals");
+	}
+
+	return;
+}
+
+# New node
+my $node = PostgreSQL::Test::Cluster->new('main');
+$node->init(extra => [ '--locale=C', '--encoding=UTF8' ]);
+$node->append_conf(
+	'postgresql.conf', q{
+wal_level = 'logical'
+max_replication_slots = 4
+max_wal_senders = 4
+});
+$node->start;
+
+# Create temporary roles and log table
+psql_command($node, 'CREATE ROLE alice WITH LOGIN;
+CREATE ROLE mallory WITH LOGIN;
+CREATE TABLE user_logins(id serial, who text);
+GRANT SELECT ON user_logins TO public;
+', 0, 'create tmp objects', log_exact => '', err_exact => ''), ;
+
+# Create login event function and trigger
+psql_command($node,
+			 'CREATE FUNCTION on_login_proc() RETURNS event_trigger AS $$
+BEGIN
+  INSERT INTO user_logins (who) VALUES (SESSION_USER);
+  IF SESSION_USER = \'mallory\' THEN
+    RAISE EXCEPTION \'Hello %! You are NOT welcome here!\', SESSION_USER;
+  END IF;
+  RAISE NOTICE \'Hello %! You are welcome!\', SESSION_USER;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+', 0, 'create trigger function', log_exact => '', err_exact => '');
+
+psql_command($node,
+			 'CREATE EVENT TRIGGER on_login_trigger '
+			 .'ON login EXECUTE PROCEDURE on_login_proc();', 0,
+			 'create event trigger', log_exact => '', err_exact => '');
+psql_command($node, 'ALTER EVENT TRIGGER on_login_trigger ENABLE ALWAYS;', 0,
+			 'alter event trigger', log_exact => '',
+			 err_like => [qr/You are welcome/]);
+
+# Check the two requests were logged via login trigger
+psql_command($node, 'SELECT COUNT(*) FROM user_logins;', 0, 'select count',
+			 log_exact => '2', err_like => [qr/You are welcome/]);
+
+# Try to log as allowed Alice and disallowed Mallory (two times)
+psql_command($node, 'SELECT 1;', 0, 'try alice', connstr => 'user=alice',
+			 log_exact => '1', err_like => [qr/You are welcome/],
+			 err_unlike => [qr/You are NOT welcome/]);
+psql_command($node, 'SELECT 1;', 2, 'try mallory', connstr => 'user=mallory',
+			 log_exact => '', err_like => [qr/You are NOT welcome/],
+			 err_unlike => [qr/You are welcome/]);
+psql_command($node, 'SELECT 1;', 2, 'try mallory', connstr => 'user=mallory',
+			 log_exact => '', err_like => [qr/You are NOT welcome/],
+			 err_unlike => [qr/You are welcome/]);
+
+# Check that Alice's login record is here, while the Mallory's one is not
+psql_command($node, 'SELECT * FROM user_logins;', 0, 'select *',
+			 log_like => [qr/3\|alice/], log_unlike => [qr/mallory/],
+			 err_like => [qr/You are welcome/]);
+
+# Check total number of successful logins so far
+psql_command($node, 'SELECT COUNT(*) FROM user_logins;', 0, 'select count',
+			 log_exact => '5', err_like => [qr/You are welcome/]);
+
+# Cleanup the temporary stuff
+psql_command($node, 'DROP EVENT TRIGGER on_login_trigger;', 0,
+			 'drop event trigger', log_exact => '',
+			 err_like => [qr/You are welcome/]);
+psql_command($node, 'DROP TABLE user_logins;
+DROP FUNCTION on_login_proc;
+DROP ROLE mallory;
+DROP ROLE alice;
+', 0, 'cleanup', log_exact => '', err_exact => '');
+
+done_testing();
diff --git a/src/test/recovery/t/001_stream_rep.pl b/src/test/recovery/t/001_stream_rep.pl
index 76846905a7..12e365aac8 100644
--- a/src/test/recovery/t/001_stream_rep.pl
+++ b/src/test/recovery/t/001_stream_rep.pl
@@ -46,6 +46,24 @@ $node_standby_2->start;
 $node_primary->safe_psql('postgres',
 	"CREATE TABLE tab_int AS SELECT generate_series(1,1002) AS a");
 
+$node_primary->safe_psql('postgres', q{
+CREATE TABLE user_logins(id serial, who text);
+
+CREATE FUNCTION on_login_proc() RETURNS EVENT_TRIGGER AS $$
+BEGIN
+  IF NOT pg_is_in_recovery() THEN
+    INSERT INTO user_logins (who) VALUES (session_user);
+  END IF;
+  IF session_user = 'regress_hacker' THEN
+    RAISE EXCEPTION 'You are not welcome!';
+  END IF;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER;
+
+CREATE EVENT TRIGGER on_login_trigger ON login EXECUTE FUNCTION on_login_proc();
+ALTER EVENT TRIGGER on_login_trigger ENABLE ALWAYS;
+});
+
 # Wait for standbys to catch up
 $node_primary->wait_for_replay_catchup($node_standby_1);
 $node_standby_1->wait_for_replay_catchup($node_standby_2, $node_primary);
@@ -384,6 +402,11 @@ sub replay_check
 
 replay_check();
 
+my $evttrig = $node_standby_1->safe_psql('postgres', "SELECT evtname FROM pg_event_trigger WHERE evtevent = 'login'");
+is($evttrig, 'on_login_trigger', 'Name of login trigger');
+$evttrig = $node_standby_2->safe_psql('postgres', "SELECT evtname FROM pg_event_trigger WHERE evtevent = 'login'");
+is($evttrig, 'on_login_trigger', 'Name of login trigger');
+
 note "enabling hot_standby_feedback";
 
 # Enable hs_feedback. The slot should gain an xmin. We set the status interval
diff --git a/src/test/regress/expected/event_trigger.out b/src/test/regress/expected/event_trigger.out
index 5a10958df5..433f5f6e95 100644
--- a/src/test/regress/expected/event_trigger.out
+++ b/src/test/regress/expected/event_trigger.out
@@ -614,3 +614,34 @@ SELECT
 DROP EVENT TRIGGER start_rls_command;
 DROP EVENT TRIGGER end_rls_command;
 DROP EVENT TRIGGER sql_drop_command;
+-- Login event triggers
+CREATE TABLE user_logins(id serial, who text);
+GRANT SELECT ON user_logins TO public;
+CREATE FUNCTION on_login_proc() RETURNS event_trigger AS $$
+BEGIN
+  INSERT INTO user_logins (who) VALUES (SESSION_USER);
+  RAISE NOTICE 'You are welcome!';
+END;
+$$ LANGUAGE plpgsql;
+CREATE EVENT TRIGGER on_login_trigger ON login EXECUTE PROCEDURE on_login_proc();
+ALTER EVENT TRIGGER on_login_trigger ENABLE ALWAYS;
+\c
+NOTICE:  You are welcome!
+SELECT COUNT(*) FROM user_logins;
+ count 
+-------
+     1
+(1 row)
+
+\c
+NOTICE:  You are welcome!
+SELECT COUNT(*) FROM user_logins;
+ count 
+-------
+     2
+(1 row)
+
+-- Cleanup
+DROP TABLE user_logins;
+DROP EVENT TRIGGER on_login_trigger;
+DROP FUNCTION on_login_proc();
diff --git a/src/test/regress/sql/event_trigger.sql b/src/test/regress/sql/event_trigger.sql
index 1aeaddbe71..420224f3ba 100644
--- a/src/test/regress/sql/event_trigger.sql
+++ b/src/test/regress/sql/event_trigger.sql
@@ -471,3 +471,24 @@ SELECT
 DROP EVENT TRIGGER start_rls_command;
 DROP EVENT TRIGGER end_rls_command;
 DROP EVENT TRIGGER sql_drop_command;
+
+-- Login event triggers
+CREATE TABLE user_logins(id serial, who text);
+GRANT SELECT ON user_logins TO public;
+CREATE FUNCTION on_login_proc() RETURNS event_trigger AS $$
+BEGIN
+  INSERT INTO user_logins (who) VALUES (SESSION_USER);
+  RAISE NOTICE 'You are welcome!';
+END;
+$$ LANGUAGE plpgsql;
+CREATE EVENT TRIGGER on_login_trigger ON login EXECUTE PROCEDURE on_login_proc();
+ALTER EVENT TRIGGER on_login_trigger ENABLE ALWAYS;
+\c
+SELECT COUNT(*) FROM user_logins;
+\c
+SELECT COUNT(*) FROM user_logins;
+
+-- Cleanup
+DROP TABLE user_logins;
+DROP EVENT TRIGGER on_login_trigger;
+DROP FUNCTION on_login_proc();
