From 8e6ff8c6afa780832132ba9ed603e2ac1f6215f8 Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Fri, 9 Oct 2026 14:03:18 +0000 Subject: [POC PATCH v2 2/6] Add JSON format support for user name maps Allow ident_file to name a JSON document, customarily pg_ident.json, in the same way as hba_file. Its "ident" key holds maps as objects keyed by system user name, with the PostgreSQL user name or a list of them as value, matched through the existing parse_ident_line(). Maps with the same name from several included documents are concatenated, and legacy pg_ident.conf files can be imported. The import handling is factored out of the hba section into helpers shared by both sections. pg_ident_file_mappings shows one row per mapping. --- doc/src/sgml/client-auth.sgml | 77 +++++++ doc/src/sgml/config.sgml | 4 +- doc/src/sgml/system-views.sgml | 4 + src/backend/libpq/hba.c | 53 ++++- src/backend/libpq/hba_json.c | 248 +++++++++++++++++++++- src/backend/utils/adt/hbafuncs.c | 45 +++- src/include/catalog/pg_proc.dat | 2 +- src/include/libpq/hba.h | 4 + src/test/authentication/t/008_json_hba.pl | 153 ++++++++++++- 9 files changed, 565 insertions(+), 25 deletions(-) diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml index 47206544f2d..09d88ac948b 100644 --- a/doc/src/sgml/client-auth.sgml +++ b/doc/src/sgml/client-auth.sgml @@ -1366,6 +1366,83 @@ omicron robert bob omicron bryanh guest1 + + + User Name Maps in a JSON Configuration File + + + pg_ident.json + + + + The user name maps can also be given as a JSON document, in the same + way as the authentication rules (see ): when + names a file whose name ends in .json (customarily + pg_ident.json), it is read as a document whose + ident key holds the maps, with an optional + include key as described there. The value is an + object with two optional keys. maps is an object + whose keys are map names and whose values are the maps. A map is an object from + system user name to database user name; the value is a string, or an + array of strings when the same system user may connect as several + database users, since JSON keys are unique. Every pair is a mapping + as one line of pg_ident.conf would be. A key + starting with a slash is a regular expression; in the value, + \1, all, + +role and a leading + slash keep their meaning. + @file is expanded in + either name, the system user name key as well as the value, as it is + in pg_ident.conf. To name a database user + spelled like a keyword, wrap the value in double quotes inside the + JSON string, as for users in the + hba section. The + example above, with the domain-stripping map added, reads: + +"ident": { + "maps": { + "omicron": {"bryanh": ["bryanh", "guest1"], + "ann": "ann", + "robert": "bob"}, + "mymap": {"/^(.*)@mydomain\\.com$": "\\1", + "/^(.*)@otherdomain\\.com$": "guest"} + } +} + + Backslashes are doubled because the JSON string syntax requires it. + + + + import names files in pg_ident.conf + format whose mappings are added to the maps they name, which allows a + gradual move to the JSON format. Like include, it + is a file name, relative to the JSON file, an object of the form + {"file": "name", "optional": + true}, or an array of either; a missing optional file is + skipped. Imported mappings come before the document's own. + + + + Where a document includes other documents, the maps of all their + ident sections are combined: a map name + used in several documents yields one map with all their mappings, the + including document's first. As the order of mappings within a map + does not matter, this simply adds mappings to the map. + + + + A mapping whose value is neither a string nor a non-empty array of + strings is reported as an error like a faulty line of + pg_ident.conf, with the file, the line of the + value and its location in the document, and the pg_ident_file_mappings + view shows the mappings and such errors in that order. An + ident value of the wrong shape, such as a map that + is not an object, prevents the maps from loading at all, and the view + reports it as an error instead of returning rows. + + diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index 46d753e8443..dda1eaa7628 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -576,7 +576,9 @@ include_dir 'conf.d' Specifies the configuration file for user name mapping - (customarily called pg_ident.conf). + (customarily called pg_ident.conf, or + pg_ident.json for the JSON format; a file + whose name ends in .json is read as JSON). This parameter can only be set at server start. See also . diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml index e641d82dd3d..fbfc8d08c7c 100644 --- a/doc/src/sgml/system-views.sgml +++ b/doc/src/sgml/system-views.sgml @@ -1587,6 +1587,10 @@ AND c1.path[c2.level] = c2.path[c2.level]; pg_ident.conf. A row appears in this view for each non-empty, non-comment line in the file, with annotations indicating whether the map could be applied successfully. + When the maps come from the ident section of a JSON + document (see ), a row appears + for each mapping, and line_number is the + line of the mapping's value. diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c index c33cc00d388..d00949c73c8 100644 --- a/src/backend/libpq/hba.c +++ b/src/backend/libpq/hba.c @@ -2942,24 +2942,57 @@ check_usermap(const char *usermap_name, bool load_ident(void) { - FILE *file; + bool use_json = (config_json_auth_source(IdentFileName) != NULL); + FILE *file = NULL; List *ident_lines = NIL; ListCell *line_cell; List *new_parsed_lines = NIL; bool ok = true; MemoryContext oldcxt; MemoryContext ident_context; + MemoryContext jsoncxt = NULL; IdentLine *newline; /* not FATAL ... we just won't do any special ident maps */ - file = open_auth_file(IdentFileName, LOG, 0, NULL); - if (file == NULL) + if (use_json) { - /* error already logged */ - return false; + List *documents; + char *err; + + /* as in load_hba(), the documents are only needed until parsed */ + jsoncxt = AllocSetContextCreate(CurrentMemoryContext, + "ident json load context", + ALLOCSET_START_SMALL_SIZES); + oldcxt = MemoryContextSwitchTo(jsoncxt); + if (!config_json_load_section("ident", IdentFileName, LOG, + &documents, &err)) + { + /* error already logged, and nothing tokenized yet */ + MemoryContextSwitchTo(oldcxt); + MemoryContextDelete(jsoncxt); + return false; + } + if (!tokenize_ident_json(documents, &ident_lines, LOG, &err)) + { + /* error already logged */ + MemoryContextSwitchTo(oldcxt); + free_json_section_tokens(); + MemoryContextDelete(jsoncxt); + return false; + } + MemoryContextSwitchTo(oldcxt); } + else + { + file = open_auth_file(IdentFileName, LOG, 0, NULL); + if (file == NULL) + { + /* error already logged */ + return false; + } - tokenize_auth_file(IdentFileName, file, &ident_lines, LOG, 0); + tokenize_auth_file(IdentFileName, file, &ident_lines, LOG, 0); + } /* Now parse all the lines */ Assert(PostmasterContext); @@ -2995,7 +3028,13 @@ load_ident(void) } /* Free tokenizer memory */ - free_auth_file(file, 0); + if (use_json) + { + free_json_section_tokens(); + MemoryContextDelete(jsoncxt); + } + else + free_auth_file(file, 0); MemoryContextSwitchTo(oldcxt); if (!ok) diff --git a/src/backend/libpq/hba_json.c b/src/backend/libpq/hba_json.c index b38662f4da3..7b87c79e2f4 100644 --- a/src/backend/libpq/hba_json.c +++ b/src/backend/libpq/hba_json.c @@ -12,6 +12,11 @@ * validates them exactly as it does for a legacy file, so the two formats * cannot drift apart in what they accept. * + * The "ident" section is flat: "maps" is an object of maps, each an object + * from system user name to PostgreSQL user name, or to an array of them. + * Every pair becomes one (map, system user, pg user) line for + * parse_ident_line(). + * * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group * Portions Copyright (c) 1994, Regents of the University of California * @@ -36,7 +41,8 @@ * method emits nothing of its own; it only groups its children. * * Import nodes splice a legacy file in at their position. Legacy lines - * are complete rules and inherit nothing. + * are complete rules and inherit nothing. The "import" list of the + * ident section works the same way. * *------------------------------------------------------------------------- */ @@ -678,8 +684,8 @@ emit_rule(JsonConfNode *node, const HbaNodeState *st, List **tok_lines, * *err set. * * The file is read at depth 1, as if included from pg_hba.conf: the - * tokenizer then uses the context tokenize_hba_json() created rather - * than making its own. An open + * tokenizer then uses the context tokenize_hba_json() or + * tokenize_ident_json() created rather than making its own. An open * failure is reported by open_auth_file() itself; the callback adds which * node asked for the file. */ @@ -873,6 +879,86 @@ section_shape_error(JsonConfNode *node, int elevel, char **file_err, return false; } +/* + * import_auth_lines + * Append the lines of the legacy files an "import" value names. + * + * The value is a string, or an array whose elements are strings or + * {"file": ..., "optional": true} objects, like the "include" list of a + * document; paths are relative to the JSON file. A malformed value is a + * section shape error. A file that cannot be opened becomes a line with + * err_msg, as a broken include does in a legacy file, unless it is + * optional and does not exist. The tokenizer context must exist. + */ +bool +import_auth_lines(JsonConfNode *imports, List **tok_lines, int elevel, + char **file_err) +{ + List *entries; + ListCell *lc; + + if (imports->type == JCN_ARRAY) + entries = imports->values; + else + entries = list_make1(imports); + + foreach(lc, entries) + { + JsonConfNode *entry = lfirst(lc); + JsonConfNode *fileval = entry; + JsonConfNode *optval = NULL; + List *lines; + char *err; + + if (entry->type == JCN_OBJECT) + { + ListCell *k, + *v; + + forboth(k, entry->keys, v, entry->values) + { + const char *key = lfirst(k); + + if (strcmp(key, "file") != 0 && strcmp(key, "optional") != 0) + return section_shape_error(lfirst(v), elevel, file_err, + "unrecognized key \"%s\"", key); + } + fileval = config_json_get(entry, "file"); + optval = config_json_get(entry, "optional"); + if (fileval == NULL) + return section_shape_error(entry, elevel, file_err, + "missing required key \"%s\"", + "file"); + if (fileval->type != JCN_STRING) + return section_shape_error(fileval, elevel, file_err, + "\"%s\" must be a string", "file"); + } + else if (entry->type != JCN_STRING) + return section_shape_error(entry, elevel, file_err, + "\"%s\" must be a string, an object, or an array of these", + "import"); + if (optval != NULL && optval->type != JCN_BOOL) + return section_shape_error(optval, elevel, file_err, + "\"%s\" must be a boolean", + "optional"); + + if (!read_import_file(fileval, optval != NULL && optval->boolval, + &lines, elevel, &err)) + { + if (err != NULL) + { + TokenizedAuthLine *tok = new_tok_line(fileval); + + tok->err_msg = err; + *tok_lines = lappend(*tok_lines, tok); + } + continue; + } + *tok_lines = list_concat(*tok_lines, lines); + } + return true; +} + /* * The "hba.children" of every document, compiled in order, so that an * included file's rules follow the including file's. @@ -904,6 +990,132 @@ compile_hba_documents(List *documents, List **tok_lines, int elevel, return true; } +/* + * One (map, system user, pg user) line. "pg" is the value node, or one + * element of an array value; the line is attributed to it, so that the + * view points at the mapping rather than at the map. Both names may be + * "@file" references, as in pg_ident.conf; parse_ident_line() rejects a + * file that holds more than one name, as it does for a legacy line. + */ +static void +emit_mapping(const char *mapname, const char *sysname, JsonConfNode *pg, + List **tok_lines, int elevel) +{ + TokenizedAuthLine *tok; + List *systoks, + *pgtoks = NIL; + char *err = NULL; + + if (pg->type != JCN_STRING) + { + hba_node_error(pg, pg, tok_lines, elevel, + "\"%s\" must be a string or a non-empty array of strings", + sysname); + return; + } + tok = new_tok_line(pg); + systoks = append_json_tokens(NIL, sysname, pg, elevel, &err); + if (err == NULL) + pgtoks = append_json_tokens(NIL, pg->strval, pg, elevel, &err); + if (err != NULL) + { + /* the @file error has been logged with the node's context already */ + tok->err_msg = err; + *tok_lines = lappend(*tok_lines, tok); + return; + } + + /* + * An empty @file yields no tokens; like the legacy tokenizer, drop the + * field rather than keep an empty one, and parse_ident_line() reports the + * missing entry. + */ + tok->fields = list_make1(list_make1(make_auth_token(mapname, false))); + if (systoks != NIL) + tok->fields = lappend(tok->fields, systoks); + if (pgtoks != NIL) + tok->fields = lappend(tok->fields, pgtoks); + tok->raw_line = rule_text(tok->fields); + *tok_lines = lappend(*tok_lines, tok); +} + +/* + * The "ident" section of every document: the imports first, then every + * key of "maps", so that maps of the same name across documents simply + * follow each other, as repeated map names do in pg_ident.conf. + */ +static bool +compile_ident_documents(List *documents, List **tok_lines, int elevel, + char **file_err) +{ + ListCell *lc; + + foreach(lc, documents) + { + JsonConfNode *doc = lfirst(lc); + JsonConfNode *ident = config_json_get(doc, "ident"); + JsonConfNode *imports, + *maps; + ListCell *k, + *v; + + if (ident == NULL) + continue; + if (ident->type != JCN_OBJECT) + return section_shape_error(ident, elevel, file_err, + "\"%s\" must be an object", "ident"); + forboth(k, ident->keys, v, ident->values) + { + const char *key = lfirst(k); + + if (strcmp(key, "import") != 0 && strcmp(key, "maps") != 0) + return section_shape_error(lfirst(v), elevel, file_err, + "unrecognized key \"%s\"", key); + } + + imports = config_json_get(ident, "import"); + if (imports != NULL && + !import_auth_lines(imports, tok_lines, elevel, file_err)) + return false; + + maps = config_json_get(ident, "maps"); + if (maps == NULL) + continue; + if (maps->type != JCN_OBJECT) + return section_shape_error(maps, elevel, file_err, + "\"%s\" must be an object", "maps"); + forboth(k, maps->keys, v, maps->values) + { + const char *mapname = lfirst(k); + JsonConfNode *map = lfirst(v); + ListCell *sk, + *sv; + + if (map->type != JCN_OBJECT) + return section_shape_error(map, elevel, file_err, + "map \"%s\" must be an object", + mapname); + forboth(sk, map->keys, sv, map->values) + { + const char *sysname = lfirst(sk); + JsonConfNode *val = lfirst(sv); + + if (val->type == JCN_ARRAY && val->values != NIL) + { + ListCell *pl; + + foreach(pl, val->values) + emit_mapping(mapname, sysname, lfirst(pl), tok_lines, + elevel); + } + else + emit_mapping(mapname, sysname, val, tok_lines, elevel); + } + } + } + return true; +} + /* * Set up the contexts a section compiler needs, and switch to the one its * output goes to. Returns the caller's context; free_json_section_tokens() @@ -920,9 +1132,9 @@ json_section_begin(void) } /* - * Frame of tokenize_hba_json(): set up the contexts, run the compiler, - * and move a file-level message out of the context the caller is about - * to free. + * Common frame of tokenize_hba_json() and tokenize_ident_json(): set up + * the contexts, run the compiler, and move a file-level message out of + * the context the caller is about to free. */ static bool tokenize_json_section(bool (*compile) (List *, List **, int, char **), @@ -970,8 +1182,28 @@ tokenize_hba_json(List *documents, List **tok_lines, int elevel, } /* - * Release what tokenize_hba_json() allocated, including the tokenizer - * context the imports used. + * tokenize_ident_json + * Compile the "ident" sections of the given documents into lines. + * + * As tokenize_hba_json(), for user name maps: each document's imports + * come first, then one line per mapping of each map in "maps". Maps with + * the same name in several documents concatenate, which falls out of the + * order. A value that is neither a string nor a non-empty array of + * strings is a per-mapping error; the shape of "ident", "maps" and the + * maps themselves is a file-level error. No "ident" section anywhere + * yields an empty list, which load_ident() accepts. + */ +bool +tokenize_ident_json(List *documents, List **tok_lines, int elevel, + char **file_err) +{ + return tokenize_json_section(compile_ident_documents, documents, + tok_lines, elevel, file_err); +} + +/* + * Release what tokenize_hba_json() or tokenize_ident_json() allocated, + * including the tokenizer context the imports used. */ void free_json_section_tokens(void) diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c index 49f9531828d..85b646aa5fc 100644 --- a/src/backend/utils/adt/hbafuncs.c +++ b/src/backend/utils/adt/hbafuncs.c @@ -566,12 +566,16 @@ fill_ident_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, } /* - * Read the pg_ident.conf file and fill the tuplestore with view records. + * fill_ident_view + * Read the user name maps and fill the tuplestore with view records. + * + * The maps come from ident_file, in either format. */ static void fill_ident_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) { - FILE *file; + bool use_json = (config_json_auth_source(IdentFileName) != NULL); + FILE *file = NULL; List *ident_lines = NIL; ListCell *line; int map_number = 0; @@ -579,14 +583,40 @@ fill_ident_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) MemoryContext oldcxt; /* - * In the unlikely event that we can't open pg_ident.conf, we throw an + * In the unlikely event that we can't open or load the file, we throw an * error, rather than trying to report it via some sort of view entry. * (Most other error conditions should result in a message in a view * entry.) */ - file = open_auth_file(IdentFileName, ERROR, 0, NULL); + if (use_json) + { + List *documents; + char *file_err; + + /* as in fill_hba_view(), free only what tokenizing created */ + if (!config_json_load_section("ident", IdentFileName, DEBUG3, + &documents, &file_err)) + ereport(ERROR, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("could not load configuration file \"%s\"", + IdentFileName), + errdetail_internal("%s", file_err))); + if (!tokenize_ident_json(documents, &ident_lines, DEBUG3, &file_err)) + { + free_json_section_tokens(); + ereport(ERROR, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("could not load configuration file \"%s\"", + IdentFileName), + errdetail_internal("%s", file_err))); + } + } + else + { + file = open_auth_file(IdentFileName, ERROR, 0, NULL); - tokenize_auth_file(IdentFileName, file, &ident_lines, DEBUG3, 0); + tokenize_auth_file(IdentFileName, file, &ident_lines, DEBUG3, 0); + } /* Now parse all the lines */ identcxt = AllocSetContextCreate(CurrentMemoryContext, @@ -612,7 +642,10 @@ fill_ident_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) } /* Free tokenizer memory */ - free_auth_file(file, 0); + if (use_json) + free_json_section_tokens(); + else + free_auth_file(file, 0); /* Free parse_ident_line memory */ MemoryContextSwitchTo(oldcxt); MemoryContextDelete(identcxt); diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index 9718218a3b6..fa56ec23d5e 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -6676,7 +6676,7 @@ proargmodes => '{o,o,o,o,o,o,o,o,o,o,o,o}', proargnames => '{rule_number,file_name,line_number,type,database,user_name,address,netmask,auth_method,options,node_path,error}', prosrc => 'pg_hba_file_rules', proacl => '{POSTGRES=X}' }, -{ oid => '6250', descr => 'show pg_ident.conf mappings', +{ oid => '6250', descr => 'show user name mappings', proname => 'pg_ident_file_mappings', prorows => '1000', proretset => 't', provolatile => 'v', prorettype => 'record', proargtypes => '', proallargtypes => '{int4,text,int4,text,text,text,text}', diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h index 37e133281b7..42870bbe602 100644 --- a/src/include/libpq/hba.h +++ b/src/include/libpq/hba.h @@ -215,7 +215,11 @@ extern List *hba_expand_auth_file(List *tokens, const char *outer_filename, struct JsonConfNode; extern bool tokenize_hba_json(List *documents, List **tok_lines, int elevel, char **file_err); +extern bool tokenize_ident_json(List *documents, List **tok_lines, int elevel, + char **file_err); extern void free_json_section_tokens(void); +extern bool import_auth_lines(struct JsonConfNode *imports, List **tok_lines, + int elevel, char **file_err); extern bool section_shape_error(struct JsonConfNode *node, int elevel, char **file_err, const char *fmt, ...) pg_attribute_printf(4, 5); diff --git a/src/test/authentication/t/008_json_hba.pl b/src/test/authentication/t/008_json_hba.pl index 312446dda3b..9d22151bf3c 100644 --- a/src/test/authentication/t/008_json_hba.pl +++ b/src/test/authentication/t/008_json_hba.pl @@ -1,7 +1,7 @@ # Copyright (c) 2026, PostgreSQL Global Development Group -# Test the JSON format of the client authentication rules, in the file -# named by hba_file. +# Test the JSON format of the client authentication rules and user name +# maps, in the files named by hba_file and ident_file. use strict; use warnings FATAL => 'all'; @@ -18,6 +18,7 @@ my $node = PostgreSQL::Test::Cluster->new('json_hba'); $node->init; my $datadir = $node->data_dir; my $hba_json = "$datadir/pg_hba.json"; +my $ident_json = "$datadir/pg_ident.json"; sub write_file { @@ -34,12 +35,21 @@ sub set_hba write_file($hba_json, qq({"hba": $hba}\n)); } +# $ident is the JSON text of the "ident" value. +sub set_ident +{ + my ($ident) = @_; + write_file($ident_json, qq({"ident": $ident}\n)); +} + $node->append_conf('postgresql.conf', <<"EOF"); listen_addresses = '127.0.0.1' hba_file = '$hba_json' +ident_file = '$ident_json' EOF set_hba( '{"children": [{"transport": "local", "auth": {"method": "trust"}}]}'); +set_ident('{}'); $node->start; # With EXEC_BACKEND, each new backend loads the rules from the files @@ -423,6 +433,145 @@ foreach my $case ( } $session->quit; +# ident maps, tested with the peer method over the Unix socket; only +# template1 goes through the map, so the test's own connections to +# postgres keep working whatever the maps say +my $sysuser = (getpwuid($<))[0]; +write_file("$datadir/mapped.txt", "carol\n"); +set_hba(<<'EOF'); +{"children": [ + {"transport": "local", "databases": "template1", + "auth": {"method": "peer", "map": "m"}}, + {"transport": "local", "auth": {"method": "trust"}} +]} +EOF +set_ident(<<"EOF"); +{ + "import": [{"file": "missing_ident.conf", "optional": true}, + "legacy_ident.conf"], + "maps": {"m": {"$sysuser": ["alice", "\@mapped.txt"], + "/^(.*)\$": "\\\\1"}} +} +EOF +write_file("$datadir/legacy_ident.conf", "m $sysuser bob\n"); +$node->reload; +$node->safe_psql('postgres', 'CREATE ROLE carol LOGIN'); +$res = $node->safe_psql( + 'postgres', q{ + SELECT map_number, regexp_replace(file_name, '.*/', ''), line_number, + map_name, sys_name, pg_username, error IS NULL + FROM pg_ident_file_mappings ORDER BY map_number}); +is( $res, + join( + "\n", + "1|legacy_ident.conf|1|m|$sysuser|bob|t", + "2|pg_ident.json|4|m|$sysuser|alice|t", + "3|pg_ident.json|4|m|$sysuser|carol|t", + "4|pg_ident.json|5|m|/^(.*)\$|\\1|t"), + 'imported mappings first, then one mapping per value element'); + +$log_offset = -s $node->logfile; +$node->psql('template1', '', connstr => 'dbname=template1 user=alice'); +my $peer_supported = !$node->log_contains( + qr/peer authentication is not supported on this platform/, + $log_offset); + +SKIP: +{ + skip 'peer authentication is not supported on this platform', 5 + unless $peer_supported; + + $node->connect_ok('dbname=template1 user=alice', + 'peer through a JSON map'); + $node->connect_ok('dbname=template1 user=bob', + 'peer through an imported legacy map'); + $node->connect_ok('dbname=template1 user=carol', + 'peer through a pg user name read from an @file'); + $node->connect_ok("dbname=template1 user=$sysuser", + 'peer through a regex key with \\1 substitution'); + $node->connect_fails( + 'dbname=template1 user=jacob', + 'system user not mapped to the requested role', + expected_stderr => qr/Peer authentication failed/); +} + +# the same map in an included document adds to it +write_file("$datadir/ident_extra.json", + qq({"ident": {"maps": {"m": {"$sysuser": "jacob"}}}}\n)); +write_file($ident_json, <<"EOF"); +{"include": ["ident_extra.json"], + "ident": {"maps": {"m": {"$sysuser": "alice"}}}} +EOF +$node->reload; +SKIP: +{ + skip 'peer authentication is not supported on this platform', 1 + unless $peer_supported; + $node->connect_ok('dbname=template1 user=jacob', + 'mapping from an included document concatenated into the map'); +} + +$res = $node->safe_psql( + 'postgres', q{ + SELECT map_number, regexp_replace(file_name, '.*/', ''), line_number, + pg_username + FROM pg_ident_file_mappings ORDER BY map_number}); +is( $res, + join("\n", "1|pg_ident.json|2|alice", "2|ident_extra.json|1|jacob"), + 'included document listed after the including one'); + +# per-mapping errors are rows in the view; the import of a missing file +# too +set_ident(<<"EOF"); +{ + "import": "missing_ident.conf", + "maps": {"m": {"a": 1, "b": [1], "c": [], "d": "/([", + "e": "\@empty.txt"}} +} +EOF +$res = $node->safe_psql( + 'postgres', q{ + SELECT map_number IS NULL, line_number, error FROM pg_ident_file_mappings + ORDER BY line_number, error}); +like($res, qr/^t\|2\|could not open file ".*missing_ident\.conf"/m, + 'missing import file reported'); +is( scalar( + () = $res =~ + /^t\|3\|"[abc]" must be a string or a non-empty array of strings/mg), + 3, 'non-string, non-string element and empty array rejected'); +like($res, qr/^t\|3\|invalid regular expression/m, + 'parse_ident_line errors pass through'); +like($res, qr/^t\|4\|missing entry at end of line/m, + 'empty @file drops the field, as in pg_ident.conf'); +is(scalar(() = $res =~ /\n/g) + 1, 6, 'one row per bad mapping or import'); +$log_offset = -s $node->logfile; +$node->reload; +$node->wait_for_log(qr/pg_ident\.json was not reloaded/, $log_offset); + +# wrong section shapes fail the whole load +foreach my $case ( + [ '[]', qr/"ident" must be an object/ ], + [ '{"maps": {}, "bogus": 1}', qr/unrecognized key "bogus"/ ], + [ '{"maps": 1}', qr/"maps" must be an object/ ], + [ '{"maps": {"m": "x"}}', qr/map "m" must be an object/ ], + [ + '{"import": [1]}', + qr/"import" must be a string, an object, or an array of these/ + ], + [ '{"import": {"file": "x", "bogus": 1}}', qr/unrecognized key "bogus"/ ], + [ '{"import": {"file": "x", "optional": 1}}', + qr/"optional" must be a boolean/ ]) +{ + my ($ident, $re) = @$case; + + set_ident($ident); + my ($ret, $out, $err) = + $node->psql('postgres', 'SELECT * FROM pg_ident_file_mappings'); + like($err, qr/could not load configuration file ".*pg_ident\.json"/, + "ident $ident: view fails"); + like($err, $re, "ident $ident: shape reported"); +} + # a document without rules: startup fails $node->stop; write_file($hba_json, "{}\n"); -- 2.56.0