From c3a4efb555c5fe9145f24c1a2765eeec51a0793a Mon Sep 17 00:00:00 2001 From: Zsolt Parragi Date: Fri, 9 Oct 2026 14:03:18 +0000 Subject: [POC PATCH v2 1/6] Add JSON format support for client authentication rules pg_hba.conf is a flat list of rules, each repeating every field. Expressing "everyone uses LDAP, except admins also need a client certificate, except this user is locked out" takes three unrelated lines, and the format has no place for structured values. Allow hba_file to name a JSON document instead, customarily pg_hba.json; the format is chosen by the file name extension. The document holds an "hba" key with a tree of nodes: each node narrows the match and carries authentication options that its children inherit. The tree is compiled depth first into flat rules, children before the node's own rule, so the most specific node wins and a flat pg_hba.conf is the special case of a tree of depth one. The flat rules go through the existing parse_hba_line(), so all validation is shared. Legacy files can be imported at any position, and an "include" key pulls in further JSON documents. pg_hba_file_rules gains a node_path column. The connection rejection messages still name pg_hba.conf; rewording them for a JSON file is left for a follow-up. --- doc/src/sgml/client-auth.sgml | 308 +++++++ doc/src/sgml/config.sgml | 5 +- doc/src/sgml/system-views.sgml | 20 +- src/backend/libpq/Makefile | 1 + src/backend/libpq/hba.c | 113 ++- src/backend/libpq/hba_json.c | 983 ++++++++++++++++++++++ src/backend/libpq/meson.build | 1 + src/backend/nls.mk | 10 +- src/backend/utils/adt/hbafuncs.c | 81 +- src/backend/utils/misc/Makefile | 1 + src/backend/utils/misc/conffiles.c | 64 +- src/backend/utils/misc/config_json.c | 958 +++++++++++++++++++++ src/backend/utils/misc/meson.build | 1 + src/include/catalog/pg_proc.dat | 8 +- src/include/libpq/hba.h | 19 + src/include/utils/conffiles.h | 5 + src/include/utils/config_json.h | 63 ++ src/test/authentication/meson.build | 1 + src/test/authentication/t/008_json_hba.pl | 433 ++++++++++ src/test/regress/expected/rules.out | 3 +- src/tools/pgindent/typedefs.list | 4 + 21 files changed, 3044 insertions(+), 38 deletions(-) create mode 100644 src/backend/libpq/hba_json.c create mode 100644 src/backend/utils/misc/config_json.c create mode 100644 src/include/utils/config_json.h create mode 100644 src/test/authentication/t/008_json_hba.pl diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml index e4e65f8feb1..47206544f2d 100644 --- a/doc/src/sgml/client-auth.sgml +++ b/doc/src/sgml/client-auth.sgml @@ -71,6 +71,8 @@ directory is initialized by . It is possible to place the authentication configuration file elsewhere, however; see the configuration parameter. + The rules can also be written as a JSON document, in the tree format + described in . @@ -891,6 +893,312 @@ local all @admins,+support scram-sha-256 local db1,db2,@demodbs all scram-sha-256 + + + + + Authentication Rules in a JSON Configuration File + + + pg_hba.json + + + + The client authentication rules can also be given as a JSON document. + When names a file whose name ends in + .json (customarily pg_hba.json), + the file is read as such a document instead of in the format described + above. The document is an object whose hba key + holds the rules; it may also have an include key, + described below, and any other key is an error. + + + + The value of hba is an object with a single key, + children, whose value is an array of + nodes. Rather than a flat list of records, the + nodes form a tree: a node carries match conditions and authentication + options that the nodes below it inherit and can override, and a node is + tried before its parent. A node is an object with these keys, all + optional: + + + + Keys of an <literal>hba</literal> Node + + + + Key + Meaning + + + + + transport + + "local" for Unix-domain socket connections or + "tcp" for TCP/IP connections. The default is + "tcp". + + + + ssl, gss + + "require" or "forbid", + constraining the connection to use, or not use, + SSL or GSSAPI encryption. + When absent, both kinds of connections match. + + + + databases, users + + A string or an array of strings, each written as the corresponding + field of a pg_hba.conf record would be: + all, sameuser, + samerole, replication, + +group, + /regex and + @file keep their + meaning. The default is all. + + + + address + + A string or an array of strings: a CIDR address range, a host name, + samehost, samenet or + all. The default is all. A + separate netmask field is not available; use CIDR notation. An IP + address without a prefix length is rejected rather than taken as a + single host; write /32 or + /128 for that. + + + + auth + + An object whose method key names the + authentication method, and whose other keys are the method's + options under their pg_hba.conf names, so + {"method": "ldap", "ldapserver": "ldap.example.com", + "ldaptls": 1} is the record + ldap ldapserver=ldap.example.com ldaptls=1. + Option values are strings, numbers or booleans; a boolean is + passed as 1 or 0. + + + + children + + An array of nodes below this one. + + + + import + + The name of a file in pg_hba.conf format, + relative to the JSON file, whose records are inserted at this + position. An import node may carry an optional + key set to true, in which case a missing file is + skipped; it may not have any other key. + + + + +
+ + + A child node starts out with the effective keys of its parent and + overrides them with its own: + + + + The match keys transport, ssl, + gss, databases, + users and address replace the + inherited value. Whether the child's value is a subset of the + parent's is not checked, and usually could not be, as with role + memberships and regular expressions. The nesting is a shorthand for + writing the inherited keys again, not a restriction: a child node + with "users": "all" below a node with + "users": "alice" matches every user, and does so + before the parent's own record is tried. Match a smaller set in a + child, or omit the key to keep the parent's. + + + + + auth is merged key by key as well, unless the + child sets method: then the child's + auth replaces the inherited one entirely, since + the inherited options belonged to a different method. A child + without a method thus adds options to the + inherited method, such as a client certificate requirement on top + of an inherited LDAP configuration. + + + + + + + The tree is turned into an ordered list of records like those of + pg_hba.conf, and connections are matched against + that list with the first match winning, exactly as described above. + The order is depth first: for each node, the records of its children + come first, in array order, followed by the node's own record. A node + emits a record of its own only if its effective auth + has a method; a node without one merely groups its + children. A node without children must have a + method, since it would otherwise do nothing. The effect is that a + more specific node, placed below the general one, is tried first, and + that a flat list of records is simply a tree of depth one. + + + + The connection type of a record is derived from + transport, ssl and + gss as shown in . ssl and + gss cannot both be constrained in one record, and a + local node cannot have an address, + ssl or gss key, whether its own + or inherited. An address array produces one record + per element. + + + + Connection Types Derived from Node Keys + + + + transport + ssl + gss + Record type + + + + + local + + + local + + + tcp + + + host + + + tcp + require + + hostssl + + + tcp + forbid + + hostnossl + + + tcp + + require + hostgssenc + + + tcp + + forbid + hostnogssenc + + + +
+ + + A string in databases, users or + address is interpreted as the unquoted field would + be in pg_hba.conf, so "all" is + the keyword. To name a database or role that happens to be spelled + like a keyword, or whose name starts with + or + @, wrap it in double quotes inside the JSON string: + "\"all\"" is the role named all. + + + + The following tree lets local users in with peer + authentication, authenticates everyone else connecting over + SSL against an LDAP server, additionally requires a + client certificate from members of the admins role, + and locks out one user. It yields four records, in this order: the + local rule, then three hostssl records: the admins + rule, the rule rejecting jacob, and the general LDAP + rule. The children inherit the ssl constraint, which + the client certificate option requires. + +"hba": {"children": [ + {"transport": "local", "auth": {"method": "peer"}}, + {"ssl": "require", + "auth": {"method": "ldap", "ldapserver": "ldap.example.com", "ldaptls": 1}, + "children": [ + {"users": "+admins", "auth": {"clientcert": "verify-full"}}, + {"users": "jacob", "auth": {"method": "reject"}} + ]} +]} + + + + + The default pg_hba.conf installed by + corresponds to this tree: + +"hba": {"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"address": ["127.0.0.1/32", "::1/128"], "auth": {"method": "trust"}}, + {"databases": "replication", "children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"address": ["127.0.0.1/32", "::1/128"], "auth": {"method": "trust"}} + ]} +]} + + + + + An import node splices an existing file in + pg_hba.conf format into the list at its position, + which allows a gradual move to the tree format. The imported records + are complete rules and do not inherit anything from the nodes above + the import node. + + + + A document can include further documents of the same form through its + include key: a file name, an object of the form + {"file": "name", "optional": + true}, or an array of either. A relative name is relative to + the including file. The last component of the name may contain the + wildcards * and ?, in which case + all matching files are included in name order. A file that does not + exist, or a pattern that matches nothing, is an error unless marked + optional. The children of the hba + sections of all documents are concatenated, the including document's + first and then its includes in order, so an included file cannot put + rules before those of the file including it. + + + + Errors in a node, including those a pg_hba.conf + record would also have, are reported with the file, the line of the + node, and its location in the document in a path notation such as + $.hba.children[1].children[0]. The pg_hba_file_rules + view shows the derived records in evaluation order, with that path in + its node_path column. +
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml index e537b3b0043..46d753e8443 100644 --- a/doc/src/sgml/config.sgml +++ b/doc/src/sgml/config.sgml @@ -558,7 +558,10 @@ include_dir 'conf.d' Specifies the configuration file for host-based authentication - (customarily called pg_hba.conf). + (customarily called pg_hba.conf, or + pg_hba.json for the JSON format described in + ; a file whose name ends in + .json is read as JSON). This parameter can only be set at server start. diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml index 77202e2c765..e641d82dd3d 100644 --- a/doc/src/sgml/system-views.sgml +++ b/doc/src/sgml/system-views.sgml @@ -1410,7 +1410,10 @@ AND c1.path[c2.level] = c2.path[c2.level]; pg_hba.conf. A row appears in this view for each non-empty, non-comment line in the file, with annotations indicating - whether the rule could be applied successfully. + whether the rule could be applied successfully. When the rules come + from the hba section of a JSON document (see ), a row appears for each rule derived + from the tree, in the order in which the rules are tried.
@@ -1534,6 +1537,18 @@ AND c1.path[c2.level] = c2.path[c2.level]; + + + node_path text + + + Location of the node this rule was derived from within a JSON + configuration file, such as + $.hba.children[1].children[0]; null for a + rule read from a file in pg_hba.conf format + + + error text @@ -1549,7 +1564,8 @@ AND c1.path[c2.level] = c2.path[c2.level]; Usually, a row reflecting an incorrect entry will have values for only - the line_number and error fields. + the line_number, node_path + and error fields. diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile index 98eb2a8242d..8a5de6294b3 100644 --- a/src/backend/libpq/Makefile +++ b/src/backend/libpq/Makefile @@ -24,6 +24,7 @@ OBJS = \ be-secure.o \ crypt.o \ hba.o \ + hba_json.o \ ifaddr.o \ pqcomm.o \ pqformat.o \ diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c index c0d8a9d8a00..c33cc00d388 100644 --- a/src/backend/libpq/hba.c +++ b/src/backend/libpq/hba.c @@ -39,6 +39,7 @@ #include "storage/fd.h" #include "utils/acl.h" #include "utils/conffiles.h" +#include "utils/config_json.h" #include "utils/guc.h" #include "utils/memutils.h" #include "utils/varlena.h" @@ -251,7 +252,7 @@ next_token(char **lineptr, StringInfo buf, /* * Construct a palloc'd AuthToken struct, copying the given string. */ -static AuthToken * +AuthToken * make_auth_token(const char *token, bool quoted) { AuthToken *authtoken; @@ -560,6 +561,26 @@ tokenize_expand_file(List *tokens, return tokens; } +/* + * hba_expand_auth_file + * Append the tokens of an @file to "tokens", for a caller outside the + * tokenizer. + * + * The JSON loader meets @file references in its own fields, after the + * tokenizer is out of the picture; the file is read as if referenced from + * a depth-1 include, so the tokenizer context must exist. On error + * *err_msg is set and the result may still be non-NIL, as for + * tokenize_expand_file(). + */ +List * +hba_expand_auth_file(List *tokens, const char *outer_filename, + const char *inc_filename, int elevel, char **err_msg) +{ + Assert(tokenize_context); + return tokenize_expand_file(tokens, outer_filename, inc_filename, elevel, + 1, err_msg); +} + /* * free_auth_file * Free a file opened by open_auth_file(). @@ -577,6 +598,33 @@ free_auth_file(FILE *file, int depth) } } +/* + * hba_tokenize_context_create + * hba_tokenize_context_delete + * Manage the tokenization context on behalf of a caller that never + * opens a top-level file. + * + * The JSON loader imports legacy files at depth 1, so neither + * open_auth_file() nor free_auth_file() would create or delete the + * context; the loader does it through these. + */ +void +hba_tokenize_context_create(void) +{ + /* as in open_auth_file(), a leftover context is assumed to be gone */ + tokenize_context = AllocSetContextCreate(CurrentMemoryContext, + "tokenize_context", + ALLOCSET_START_SMALL_SIZES); +} + +void +hba_tokenize_context_delete(void) +{ + if (tokenize_context != NULL) + MemoryContextDelete(tokenize_context); + tokenize_context = NULL; +} + /* * open_auth_file * Open the given file. @@ -1343,6 +1391,13 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel) parsedline->linenumber = line_num; parsedline->rawline = pstrdup(tok_line->raw_line); + /* + * A rule compiled from the JSON tree carries its node path outside the + * token stream; copy it into our context as well. + */ + if (tok_line->node_path != NULL) + parsedline->node_path = pstrdup(tok_line->node_path); + /* Check the record type. */ Assert(tok_line->fields != NIL); field = list_head(tok_line->fields); @@ -2451,22 +2506,58 @@ check_hba(Port *port) bool load_hba(void) { - FILE *file; + bool use_json = (config_json_auth_source(HbaFileName) != NULL); + FILE *file = NULL; List *hba_lines = NIL; ListCell *line; List *new_parsed_lines = NIL; bool ok = true; MemoryContext oldcxt; MemoryContext hbacxt; + MemoryContext jsoncxt = NULL; - file = open_auth_file(HbaFileName, LOG, 0, NULL); - if (file == NULL) + if (use_json) { - /* error already logged */ - return false; + List *documents; + char *err; + + /* + * The documents and the compiled lines are only needed until the + * lines are parsed; keep them out of the long-lived contexts. + */ + jsoncxt = AllocSetContextCreate(CurrentMemoryContext, + "hba json load context", + ALLOCSET_START_SMALL_SIZES); + oldcxt = MemoryContextSwitchTo(jsoncxt); + if (!config_json_load_section("hba", HbaFileName, LOG, &documents, + &err)) + { + /* error already logged, and nothing tokenized yet */ + MemoryContextSwitchTo(oldcxt); + MemoryContextDelete(jsoncxt); + return false; + } + if (!tokenize_hba_json(documents, &hba_lines, LOG, &err)) + { + /* error already logged */ + MemoryContextSwitchTo(oldcxt); + free_json_section_tokens(); + MemoryContextDelete(jsoncxt); + return false; + } + MemoryContextSwitchTo(oldcxt); } + else + { + file = open_auth_file(HbaFileName, LOG, 0, NULL); + if (file == NULL) + { + /* error already logged */ + return false; + } - tokenize_auth_file(HbaFileName, file, &hba_lines, LOG, 0); + tokenize_auth_file(HbaFileName, file, &hba_lines, LOG, 0); + } /* Now parse all the lines */ Assert(PostmasterContext); @@ -2517,7 +2608,13 @@ load_hba(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 new file mode 100644 index 00000000000..b38662f4da3 --- /dev/null +++ b/src/backend/libpq/hba_json.c @@ -0,0 +1,983 @@ +/*------------------------------------------------------------------------- + * + * hba_json.c + * Compile the JSON hba tree and ident maps. + * + * The "hba" section of a JSON configuration is a tree of nodes. Each node + * narrows the match (transport, ssl, gss, databases, users, address) and + * carries authentication options (auth) that its children inherit. We + * walk the tree depth first, children before the node's own rule, and emit + * one TokenizedAuthLine per node that has an authentication method, with + * the fields in the classic pg_hba.conf order. parse_hba_line() then + * validates them exactly as it does for a legacy file, so the two formats + * cannot drift apart in what they accept. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * + * IDENTIFICATION + * src/backend/libpq/hba_json.c + * + * NOTES + * + * Inheritance works on an "effective node", built from the parent's + * effective node by applying the child's own keys: + * + * - match fields replace the inherited value wholesale; + * - "auth" merges key by key, unless the child sets "method", in which + * case the child's "auth" replaces the parent's entirely, since the + * parent's options belong to a method that is no longer in use. + * + * Emission order is what makes the tree readable as "the most specific + * node wins": a subtree is emitted before the node's own rule, so a child + * that narrows the match is tried first, and a flat pg_hba.conf is the + * special case of a tree of depth one. A node without an effective + * 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. + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "common/ip.h" +#include "libpq/hba.h" +#include "miscadmin.h" +#include "utils/conffiles.h" +#include "utils/config_json.h" +#include "utils/memutils.h" + +/* + * Everything the tokenize_*_json() functions allocate while compiling + * lives here. + */ +static MemoryContext hba_json_context = NULL; + +/* + * Effective (inherited) view of one node. Match fields point at the JSON + * value that set them, so that an error can tell an inherited constraint + * from the node's own; NULL means the default. + */ +typedef struct HbaNodeState +{ + JsonConfNode *transport; /* NULL = default "tcp" */ + JsonConfNode *ssl; /* NULL = no constraint */ + JsonConfNode *gss; /* NULL = no constraint */ + JsonConfNode *databases; /* NULL = "all" */ + JsonConfNode *users; /* NULL = "all" */ + JsonConfNode *address; /* NULL = "all" */ + List *auth_keys; /* char *, "method" included */ + List *auth_vals; /* JsonConfNode * */ +} HbaNodeState; + +static void walk_node(JsonConfNode *node, const HbaNodeState *parent, + List **tok_lines, int elevel); + + +/* + * Replace or append a key in a pair of parallel lists. Insertion order is + * kept, so inherited keys stay in front of the node's own. + */ +static void +kv_put(List **keys, List **vals, const char *key, void *val) +{ + ListCell *k, + *v; + + forboth(k, *keys, v, *vals) + { + if (strcmp(lfirst(k), key) == 0) + { + lfirst(v) = val; + return; + } + } + *keys = lappend(*keys, pstrdup(key)); + *vals = lappend(*vals, val); +} + +static void * +kv_get(List *keys, List *vals, const char *key) +{ + ListCell *k, + *v; + + forboth(k, keys, v, vals) + { + if (strcmp(lfirst(k), key) == 0) + return lfirst(v); + } + return NULL; +} + +/* + * A line that carries the node's identity, but no fields yet. raw_line is + * the node path until the caller knows the rule text. + */ +static TokenizedAuthLine * +new_tok_line(JsonConfNode *node) +{ + TokenizedAuthLine *tok = palloc0_object(TokenizedAuthLine); + + tok->file_name = pstrdup(node->file); + tok->line_num = node->line; + tok->node_path = config_json_path(node); + tok->raw_line = pstrdup(tok->node_path); + return tok; +} + +/* + * Error context callback for anything reported while working on a node: + * the location in the document and the file position, as the tokenizer + * gives the line of a legacy file. + */ +static void +node_error_callback(void *arg) +{ + (void) config_json_errcontext((JsonConfNode *) arg); +} + +/* + * Record a per-node error: report it at elevel, with the location of "at" + * as context, and add a line with no fields and the message in err_msg, so + * that the views show it in place. "at" is the node the message is about, + * which may be a value inside "node"; the line itself is attributed to + * "node". The message is translated here. + */ +static void hba_node_error(JsonConfNode *node, JsonConfNode *at, + List **tok_lines, int elevel, + const char *fmt, ...) pg_attribute_printf(5, 6); + +static void +hba_node_error(JsonConfNode *node, JsonConfNode *at, List **tok_lines, + int elevel, const char *fmt, ...) +{ + TokenizedAuthLine *tok = new_tok_line(node); + StringInfoData msg; + + fmt = _(fmt); + initStringInfo(&msg); + for (;;) + { + va_list args; + int needed; + + va_start(args, fmt); + needed = appendStringInfoVA(&msg, fmt, args); + va_end(args); + if (needed == 0) + break; + enlargeStringInfo(&msg, needed); + } + tok->err_msg = msg.data; + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", msg.data), + config_json_errcontext(at))); + *tok_lines = lappend(*tok_lines, tok); +} + +/* The keys of an hba node other than an import node. */ +static const char *const node_keys[] = { + "transport", "ssl", "gss", "databases", "users", "address", "auth", + "children", NULL +}; + +/* Is the string one of the given keywords? The list ends with NULL. */ +static bool +string_in(const char *s, const char *const *keywords) +{ + for (int i = 0; keywords[i] != NULL; i++) + { + if (strcmp(s, keywords[i]) == 0) + return true; + } + return false; +} + +/* + * Check the JSON type, and for the enumerations the value, of one key of a + * node. Returns NULL if acceptable, else the message. Checking the + * enumerations here rather than at emission reports a bad value on a + * grouping node even when every child overrides it. + */ +static char * +check_key_type(const char *key, JsonConfNode *val) +{ + static const char *const transports[] = {"local", "tcp", NULL}; + static const char *const constraints[] = {"require", "forbid", NULL}; + + if (strcmp(key, "transport") == 0 || strcmp(key, "ssl") == 0 || + strcmp(key, "gss") == 0) + { + if (val->type != JCN_STRING) + return psprintf(_("\"%s\" must be a string"), key); + if (!string_in(val->strval, + strcmp(key, "transport") == 0 ? transports : constraints)) + return psprintf(_("invalid value for \"%s\": \"%s\""), key, + val->strval); + } + else if (strcmp(key, "databases") == 0 || strcmp(key, "users") == 0 || + strcmp(key, "address") == 0) + { + bool ok = (val->type == JCN_STRING); + + if (val->type == JCN_ARRAY && val->values != NIL) + { + ListCell *lc; + + ok = true; + foreach(lc, val->values) + { + if (((JsonConfNode *) lfirst(lc))->type != JCN_STRING) + ok = false; + } + } + if (!ok) + return psprintf(_("\"%s\" must be a string or a non-empty array of strings"), + key); + } + else if (strcmp(key, "auth") == 0) + { + if (val->type != JCN_OBJECT) + return psprintf(_("\"%s\" must be an object"), key); + } + else if (strcmp(key, "children") == 0) + { + if (val->type != JCN_ARRAY) + return psprintf(_("\"%s\" must be an array"), key); + } + else + return psprintf(_("unrecognized key \"%s\""), key); + + return NULL; +} + +/* + * Build the effective state of "node" from its parent's state, applying + * the inheritance rules described in the file header. Returns false with + * *err set and *at pointing at the offending value for structural errors + * (unrecognized keys, wrong JSON types, bad enumeration values); how the + * match fields combine is checked later, when the type keyword is + * synthesized. + */ +static bool +merge_node(const HbaNodeState *parent, JsonConfNode *node, + HbaNodeState *out, char **err, JsonConfNode **at) +{ + ListCell *k, + *v; + JsonConfNode *val, + *auth; + + *out = *parent; + out->auth_keys = list_copy(parent->auth_keys); + out->auth_vals = list_copy(parent->auth_vals); + + forboth(k, node->keys, v, node->values) + { + *at = lfirst(v); + if ((*err = check_key_type(lfirst(k), *at)) != NULL) + return false; + } + + /* match fields replace wholesale */ + if ((val = config_json_get(node, "transport")) != NULL) + out->transport = val; + if ((val = config_json_get(node, "ssl")) != NULL) + out->ssl = val; + if ((val = config_json_get(node, "gss")) != NULL) + out->gss = val; + if ((val = config_json_get(node, "databases")) != NULL) + out->databases = val; + if ((val = config_json_get(node, "users")) != NULL) + out->users = val; + if ((val = config_json_get(node, "address")) != NULL) + out->address = val; + + /* + * auth merges key by key, unless the method changes: the inherited + * options were meant for the old method, so start over. + */ + auth = config_json_get(node, "auth"); + if (auth != NULL) + { + if (config_json_get(auth, "method") != NULL) + { + out->auth_keys = NIL; + out->auth_vals = NIL; + } + forboth(k, auth->keys, v, auth->values) + { + JsonConfNode *o = lfirst(v); + + *at = o; + if (strcmp(lfirst(k), "method") == 0) + { + if (o->type != JCN_STRING) + { + *err = psprintf(_("\"%s\" must be a string"), "method"); + return false; + } + } + else if (o->type != JCN_STRING && o->type != JCN_NUMBER && + o->type != JCN_BOOL) + { + *err = psprintf(_("\"%s\" must be a string, number, or boolean"), + (char *) lfirst(k)); + return false; + } + kv_put(&out->auth_keys, &out->auth_vals, lfirst(k), o); + } + } + + return true; +} + +/* + * The token for one JSON string. The string is an unquoted hba token: + * "all", "+group" and friends keep their keyword meaning. Wrapping the + * string in double quotes marks a literal, as it does in pg_hba.conf, so + * "\"all\"" is a role named all. + */ +static AuthToken * +make_json_auth_token(const char *s) +{ + size_t len = strlen(s); + + if (len >= 2 && s[0] == '"' && s[len - 1] == '"') + { + char *unwrapped = pnstrdup(s + 1, len - 2); + AuthToken *tok = make_auth_token(unwrapped, true); + + pfree(unwrapped); + return tok; + } + return make_auth_token(s, false); +} + +/* + * Tokens for one JSON string in a core field: as make_json_auth_token(), + * except that "@file" pulls in the file's contents, relative to the JSON + * file. "at" is the node the string belongs to, which is its value node + * or, for an object key, the key's value. On error *err is set. + */ +static List * +append_json_tokens(List *tokens, const char *s, JsonConfNode *at, + int elevel, char **err) +{ + if (s[0] == '@' && s[1] != '\0') + { + ErrorContextCallback errcallback; + + /* the file's own problems get reported against this node */ + errcallback.callback = node_error_callback; + errcallback.arg = at; + errcallback.previous = error_context_stack; + error_context_stack = &errcallback; + tokens = hba_expand_auth_file(tokens, at->file, s + 1, elevel, err); + error_context_stack = errcallback.previous; + return tokens; + } + return lappend(tokens, make_json_auth_token(s)); +} + +/* + * Tokens for a databases/users/address value; NULL means the default. + * Returns NIL with *err set on error. + */ +static List * +string_list_tokens(JsonConfNode *val, int elevel, char **err) +{ + List *tokens = NIL; + ListCell *lc; + + *err = NULL; + if (val == NULL) + return list_make1(make_auth_token("all", false)); + if (val->type == JCN_STRING) + tokens = append_json_tokens(tokens, val->strval, val, elevel, err); + else + { + foreach(lc, val->values) + { + JsonConfNode *elem = lfirst(lc); + + tokens = append_json_tokens(tokens, elem->strval, elem, elevel, + err); + if (*err != NULL) + break; + } + } + return *err == NULL ? tokens : NIL; +} + +/* + * Synthesize the classic type keyword from transport, ssl and gss, whose + * values merge_node() has checked. Returns NULL with *err set when the + * combination has no classic equivalent. "node" is the node being + * emitted, so that a constraint set by an ancestor can be named as such. + */ +static const char * +type_keyword(JsonConfNode *node, const HbaNodeState *st, char **err) +{ + const char *sslv = st->ssl ? st->ssl->strval : NULL; + const char *gssv = st->gss ? st->gss->strval : NULL; + + if (st->transport != NULL && strcmp(st->transport->strval, "local") == 0) + { + /* + * A Unix socket has no address and no transport encryption. An + * explicitly set constraint is an error even when inherited, since + * silently dropping it would widen the match; the default "all" for + * the address is fine. Name the inherited key, as the reader of this + * node cannot see it. + */ + JsonConfNode *own[3] = {st->address, st->ssl, st->gss}; + const char *names[3] = {"address", "ssl", "gss"}; + const char *inherited = NULL; + + for (int i = 0; i < lengthof(own); i++) + { + if (own[i] == NULL) + continue; + if (own[i]->parent == node) + { + *err = pstrdup(_("\"address\", \"ssl\" and \"gss\" cannot be used with transport \"local\"")); + return NULL; + } + if (inherited == NULL) + inherited = names[i]; + } + if (inherited != NULL) + { + *err = psprintf(_("inherited \"%s\" cannot be used with transport \"local\""), + inherited); + return NULL; + } + return "local"; + } + + /* the classic keywords constrain one of the two, never both */ + if (sslv != NULL && gssv != NULL) + { + *err = pstrdup(_("\"ssl\" and \"gss\" cannot both be constrained")); + return NULL; + } + if (sslv != NULL) + return strcmp(sslv, "require") == 0 ? "hostssl" : "hostnossl"; + if (gssv != NULL) + return strcmp(gssv, "require") == 0 ? "hostgssenc" : "hostnogssenc"; + return "host"; +} + +/* + * The classic text of a rule built from the given fields, for the "line + * matched" messages: the JSON node alone does not show what it inherited. + */ +static char * +rule_text(List *fields) +{ + StringInfoData buf; + ListCell *f; + + initStringInfo(&buf); + foreach(f, fields) + { + ListCell *t; + bool first = true; + + if (buf.len > 0) + appendStringInfoChar(&buf, ' '); + foreach(t, (List *) lfirst(f)) + { + AuthToken *tok = lfirst(t); + + if (!first) + appendStringInfoChar(&buf, ','); + first = false; + if (tok->quoted) + appendStringInfo(&buf, "\"%s\"", tok->string); + else + appendStringInfoString(&buf, tok->string); + } + } + return buf.data; +} + +/* + * Is the address token a bare IP address? parse_hba_line() would take the + * next field, the method, for its netmask, which a JSON rule cannot have. + * Host names and the keywords do not parse as numeric addresses. + */ +static bool +is_bare_ip_address(const AuthToken *addr) +{ + struct addrinfo hints; + struct addrinfo *result = NULL; + int ret; + + if (strchr(addr->string, '/') != NULL) + return false; + memset(&hints, 0, sizeof(hints)); + hints.ai_flags = AI_NUMERICHOST; + hints.ai_family = AF_UNSPEC; + ret = pg_getaddrinfo_all(addr->string, NULL, &hints, &result); + if (result) + pg_freeaddrinfo_all(hints.ai_family, result); + return ret == 0; +} + +/* + * The element of an "address" value an address token came from, for the + * error context; the value itself for a string, or a token from an @file. + */ +static JsonConfNode * +address_node(JsonConfNode *address, const AuthToken *addr) +{ + ListCell *lc; + + if (address->type == JCN_ARRAY) + { + foreach(lc, address->values) + { + JsonConfNode *el = lfirst(lc); + + if (strcmp(el->strval, addr->string) == 0) + return el; + } + } + return address; +} + +/* + * Emit the node's own rule(s): one per address element, all sharing the + * node path. The fields follow the classic order, type, databases, users, + * address (not for "local"), method, then name=value options, inherited + * options first. + */ +static void +emit_rule(JsonConfNode *node, const HbaNodeState *st, List **tok_lines, + int elevel) +{ + char *err = NULL; + const char *type; + List *databases, + *users, + *addresses = NIL; + ListCell *lc; + JsonConfNode *method = kv_get(st->auth_keys, st->auth_vals, "method"); + + if ((type = type_keyword(node, st, &err)) == NULL) + { + hba_node_error(node, node, tok_lines, elevel, "%s", err); + return; + } + + /* + * The lists are shared by the emitted lines; parse_hba_line() copies the + * tokens it keeps, so that is safe. + */ + databases = string_list_tokens(st->databases, elevel, &err); + if (err == NULL) + users = string_list_tokens(st->users, elevel, &err); + if (err == NULL) + { + if (strcmp(type, "local") == 0) + addresses = list_make1(NULL); + else + addresses = string_list_tokens(st->address, elevel, &err); + } + if (err != NULL) + { + /* the @file error has been logged with the node's context already */ + TokenizedAuthLine *tok = new_tok_line(node); + + 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, so that parse_hba_line() rejects + * the line rather than the rule silently matching nothing. + */ + if (addresses == NIL) + addresses = list_make1(NULL); + + foreach(lc, addresses) + { + AuthToken *addr = lfirst(lc); + TokenizedAuthLine *tok = new_tok_line(node); + List *fields; + ListCell *k, + *v; + + /* + * A bare IP address is rejected here, for each element, rather than + * leaving parse_hba_line() to complain about the method as a netmask. + */ + if (addr != NULL && is_bare_ip_address(addr)) + { + tok->err_msg = psprintf(_("address \"%s\" must be in CIDR notation"), + addr->string); + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", tok->err_msg), + errhint("Add \"/32\" for an IPv4 address or \"/128\" for an IPv6 address."), + config_json_errcontext(address_node(st->address, addr)))); + *tok_lines = lappend(*tok_lines, tok); + continue; + } + + fields = list_make1(list_make1(make_auth_token(type, false))); + if (databases != NIL) + fields = lappend(fields, databases); + if (users != NIL) + fields = lappend(fields, users); + if (addr != NULL) + fields = lappend(fields, list_make1(addr)); + fields = lappend(fields, + list_make1(make_auth_token(method->strval, false))); + forboth(k, st->auth_keys, v, st->auth_vals) + { + JsonConfNode *o = lfirst(v); + const char *text; + + if (strcmp(lfirst(k), "method") == 0) + continue; + + /* + * Boolean options are "1" or "0" in pg_hba.conf, not on/off, so + * do not use config_json_scalar_text() here. + */ + text = (o->type == JCN_BOOL) ? (o->boolval ? "1" : "0") : o->strval; + + fields = lappend(fields, + list_make1(make_auth_token(psprintf("%s=%s", + (char *) lfirst(k), + text), + false))); + } + tok->fields = fields; + tok->raw_line = rule_text(fields); + *tok_lines = lappend(*tok_lines, tok); + } +} + +/* + * Read a legacy file for an import. "fileval" is the string node naming + * it, relative to the JSON file that contains the node. Returns false + * when there is nothing to splice in: an optional file that does not + * exist, with *err left NULL, or a file that could not be opened, with + * *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 + * failure is reported by open_auth_file() itself; the callback adds which + * node asked for the file. + */ +static bool +read_import_file(JsonConfNode *fileval, bool optional, List **lines, + int elevel, char **err) +{ + char *path = AbsoluteConfigLocation(fileval->strval, fileval->file); + FILE *file; + ErrorContextCallback errcallback; + + *lines = NIL; + *err = NULL; + + /* a missing optional file is not worth a log line */ + if (optional) + { + struct stat st_buf; + + if (stat(path, &st_buf) != 0 && errno == ENOENT) + return false; + } + + errcallback.callback = node_error_callback; + errcallback.arg = fileval; + errcallback.previous = error_context_stack; + error_context_stack = &errcallback; + file = open_auth_file(path, elevel, 1, err); + error_context_stack = errcallback.previous; + if (file == NULL) + return false; + tokenize_auth_file(path, file, lines, elevel, 1); + free_auth_file(file, 1); + return true; +} + +/* + * Splice a legacy file in at the import node's position. The legacy lines + * are complete rules and inherit nothing. + */ +static void +import_node(JsonConfNode *node, List **tok_lines, int elevel) +{ + JsonConfNode *imp = config_json_get(node, "import"); + JsonConfNode *opt = config_json_get(node, "optional"); + ListCell *k, + *v; + List *lines; + char *err; + + /* an import node is a leaf with exactly these two keys */ + forboth(k, node->keys, v, node->values) + { + const char *key = lfirst(k); + + if (strcmp(key, "import") == 0 || strcmp(key, "optional") == 0) + continue; + if (string_in(key, node_keys)) + hba_node_error(node, lfirst(v), tok_lines, elevel, + "import node cannot have \"%s\"", key); + else + hba_node_error(node, lfirst(v), tok_lines, elevel, + "unrecognized key \"%s\"", key); + return; + } + if (imp->type != JCN_STRING) + { + hba_node_error(node, imp, tok_lines, elevel, + "\"%s\" must be a string", "import"); + return; + } + if (opt != NULL && opt->type != JCN_BOOL) + { + hba_node_error(node, opt, tok_lines, elevel, + "\"%s\" must be a boolean", "optional"); + return; + } + if (!read_import_file(imp, opt != NULL && opt->boolval, &lines, elevel, + &err)) + { + if (err != NULL) + { + TokenizedAuthLine *tok = new_tok_line(node); + + tok->err_msg = err; + *tok_lines = lappend(*tok_lines, tok); + } + return; + } + + *tok_lines = list_concat(*tok_lines, lines); +} + +/* + * Compile one node and its subtree, appending lines in evaluation order. + */ +static void +walk_node(JsonConfNode *node, const HbaNodeState *parent, List **tok_lines, + int elevel) +{ + HbaNodeState st; + char *err = NULL; + JsonConfNode *at = NULL; + JsonConfNode *children; + bool has_children; + + /* the tree is user input, and this recurses with it */ + check_stack_depth(); + + if (node->type != JCN_OBJECT) + { + hba_node_error(node, node, tok_lines, elevel, + "hba node must be an object, not %s", + config_json_type_name(node->type)); + return; + } + + if (config_json_get(node, "import") != NULL) + { + import_node(node, tok_lines, elevel); + return; + } + + if (!merge_node(parent, node, &st, &err, &at)) + { + hba_node_error(node, at, tok_lines, elevel, "%s", err); + return; + } + + /* children first: the more specific match must be tried first */ + children = config_json_get(node, "children"); + has_children = (children != NULL && children->values != NIL); + if (has_children) + { + ListCell *lc; + + foreach(lc, children->values) + walk_node(lfirst(lc), &st, tok_lines, elevel); + } + + if (kv_get(st.auth_keys, st.auth_vals, "method") != NULL) + emit_rule(node, &st, tok_lines, elevel); + else if (!has_children) + { + /* + * A grouping node without a method is fine, but a leaf without one + * emits nothing and is almost certainly a mistake. An empty children + * array is a leaf. + */ + hba_node_error(node, node, tok_lines, elevel, + "leaf node has no authentication method"); + } +} + +/* + * section_shape_error + * Report a value of the wrong shape in a section, which leaves nothing + * to compile for that section. + * + * Logs at elevel with the node's location as context, sets *file_err to + * the message with the location appended, and returns false for the + * caller to pass up. The message is translated here, and allocated in + * the current memory context. + */ +bool +section_shape_error(JsonConfNode *node, int elevel, char **file_err, + const char *fmt, ...) +{ + StringInfoData msg; + + fmt = _(fmt); + initStringInfo(&msg); + for (;;) + { + va_list args; + int needed; + + va_start(args, fmt); + needed = appendStringInfoVA(&msg, fmt, args); + va_end(args); + if (needed == 0) + break; + enlargeStringInfo(&msg, needed); + } + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", msg.data), + config_json_errcontext(node))); + if (file_err) + *file_err = config_json_error_text(node, msg.data); + return false; +} + +/* + * The "hba.children" of every document, compiled in order, so that an + * included file's rules follow the including file's. + */ +static bool +compile_hba_documents(List *documents, List **tok_lines, int elevel, + char **file_err) +{ + ListCell *lc; + HbaNodeState root = {0}; + + foreach(lc, documents) + { + JsonConfNode *doc = lfirst(lc); + JsonConfNode *hba = config_json_get(doc, "hba"); + JsonConfNode *children; + ListCell *c; + + if (hba == NULL) + continue; + if (hba->type != JCN_OBJECT || + (children = config_json_get(hba, "children")) == NULL || + children->type != JCN_ARRAY || list_length(hba->keys) != 1) + return section_shape_error(hba, elevel, file_err, + "\"hba\" must be an object with a \"children\" array"); + foreach(c, children->values) + walk_node(lfirst(c), &root, 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() + * undoes the rest. + */ +static MemoryContext +json_section_begin(void) +{ + hba_json_context = AllocSetContextCreate(CurrentMemoryContext, + "hba json context", + ALLOCSET_START_SMALL_SIZES); + hba_tokenize_context_create(); + return MemoryContextSwitchTo(hba_json_context); +} + +/* + * 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. + */ +static bool +tokenize_json_section(bool (*compile) (List *, List **, int, char **), + List *documents, List **tok_lines, int elevel, + char **file_err) +{ + MemoryContext oldcxt; + bool ok; + + *tok_lines = NIL; + if (file_err) + *file_err = NULL; + + oldcxt = json_section_begin(); + ok = compile(documents, tok_lines, elevel, file_err); + MemoryContextSwitchTo(oldcxt); + + if (!ok && file_err && *file_err != NULL) + *file_err = pstrdup(*file_err); + return ok; +} + +/* + * tokenize_hba_json + * Compile the "hba" sections of the given documents into lines. + * + * "documents" is the list from config_json_load_section(); the + * "hba.children" of every document are compiled in order, so an included + * file's rules follow the including file's. Returns false with *file_err + * set for file-level problems (an "hba" value of the wrong shape); per-node + * problems are reported through err_msg on the lines, as the tokenizer + * does for a legacy file. No "hba" section anywhere yields an empty list, + * which load_hba() rejects as a file without entries. + * + * The lines and everything they point to are allocated in a context of + * their own, released by free_json_section_tokens(); callers copy what + * they keep, as parse_hba_line() does. + */ +bool +tokenize_hba_json(List *documents, List **tok_lines, int elevel, + char **file_err) +{ + return tokenize_json_section(compile_hba_documents, documents, tok_lines, + elevel, file_err); +} + +/* + * Release what tokenize_hba_json() allocated, including the tokenizer + * context the imports used. + */ +void +free_json_section_tokens(void) +{ + hba_tokenize_context_delete(); + if (hba_json_context != NULL) + MemoryContextDelete(hba_json_context); + hba_json_context = NULL; +} diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build index 8571f652844..619584e2399 100644 --- a/src/backend/libpq/meson.build +++ b/src/backend/libpq/meson.build @@ -10,6 +10,7 @@ backend_sources += files( 'be-secure.c', 'crypt.c', 'hba.c', + 'hba_json.c', 'ifaddr.c', 'pqcomm.c', 'pqformat.c', diff --git a/src/backend/nls.mk b/src/backend/nls.mk index 698b1083f4b..26578061db9 100644 --- a/src/backend/nls.mk +++ b/src/backend/nls.mk @@ -16,7 +16,10 @@ GETTEXT_TRIGGERS = $(BACKEND_COMMON_GETTEXT_TRIGGERS) \ ereport_startup_progress \ json_token_error:2 \ json_manifest_parse_failure:2 \ - error_cb:2 + error_cb:2 \ + json_setting_error:5 \ + hba_node_error:5 \ + section_shape_error:4 GETTEXT_FLAGS = $(BACKEND_COMMON_GETTEXT_FLAGS) \ GUC_check_errmsg:1:c-format \ GUC_check_errdetail:1:c-format \ @@ -25,7 +28,10 @@ GETTEXT_FLAGS = $(BACKEND_COMMON_GETTEXT_FLAGS) \ report_invalid_record:2:c-format \ ereport_startup_progress:1:c-format \ json_token_error:2:c-format \ - error_cb:2:c-format + error_cb:2:c-format \ + json_setting_error:5:c-format \ + hba_node_error:5:c-format \ + section_shape_error:4:c-format gettext-files: generated-parser-sources generated-headers find $(srcdir) $(srcdir)/../common $(srcdir)/../port $(srcdir)/../include/ \( -name '*.c' -o -name "proctypelist.h" \) -print | LC_ALL=C sort >$@ diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c index bd23eda3f79..49f9531828d 100644 --- a/src/backend/utils/adt/hbafuncs.c +++ b/src/backend/utils/adt/hbafuncs.c @@ -21,6 +21,7 @@ #include "libpq/hba.h" #include "utils/array.h" #include "utils/builtins.h" +#include "utils/config_json.h" #include "utils/guc.h" #include "utils/tuplestore.h" @@ -28,7 +29,8 @@ static ArrayType *get_hba_options(HbaLine *hba); static void fill_hba_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, int rule_number, char *filename, int lineno, - HbaLine *hba, const char *err_msg); + const char *node_path, HbaLine *hba, + const char *err_msg); static void fill_hba_view(Tuplestorestate *tuple_store, TupleDesc tupdesc); static void fill_ident_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, int map_number, char *filename, int lineno, @@ -164,7 +166,7 @@ get_hba_options(HbaLine *hba) } /* Number of columns in pg_hba_file_rules view */ -#define NUM_PG_HBA_FILE_RULES_ATTS 11 +#define NUM_PG_HBA_FILE_RULES_ATTS 12 /* * fill_hba_line @@ -175,6 +177,7 @@ get_hba_options(HbaLine *hba) * rule_number: unique identifier among all valid rules * filename: configuration file name (must always be valid) * lineno: line number of configuration file (must always be valid) + * node_path: JSON node the rule was compiled from (NULL for a legacy line) * hba: parsed line data (can be NULL, in which case err_msg should be set) * err_msg: error message (NULL if none) * @@ -183,8 +186,8 @@ get_hba_options(HbaLine *hba) */ static void fill_hba_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, - int rule_number, char *filename, int lineno, HbaLine *hba, - const char *err_msg) + int rule_number, char *filename, int lineno, + const char *node_path, HbaLine *hba, const char *err_msg) { Datum values[NUM_PG_HBA_FILE_RULES_ATTS]; bool nulls[NUM_PG_HBA_FILE_RULES_ATTS]; @@ -354,15 +357,23 @@ fill_hba_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, } else { - /* no parsing result, so set relevant fields to nulls */ - memset(&nulls[3], true, (NUM_PG_HBA_FILE_RULES_ATTS - 4) * sizeof(bool)); + /* no parsing result, so set the fields derived from it to nulls */ + memset(&nulls[3], true, (NUM_PG_HBA_FILE_RULES_ATTS - 5) * sizeof(bool)); + index = NUM_PG_HBA_FILE_RULES_ATTS - 2; } + /* node_path, known even when the rule did not parse */ + if (node_path) + values[index++] = CStringGetTextDatum(node_path); + else + nulls[index++] = true; + /* error */ + Assert(index == NUM_PG_HBA_FILE_RULES_ATTS - 1); if (err_msg) - values[NUM_PG_HBA_FILE_RULES_ATTS - 1] = CStringGetTextDatum(err_msg); + values[index] = CStringGetTextDatum(err_msg); else - nulls[NUM_PG_HBA_FILE_RULES_ATTS - 1] = true; + nulls[index] = true; tuple = heap_form_tuple(tupdesc, values, nulls); tuplestore_puttuple(tuple_store, tuple); @@ -370,12 +381,15 @@ fill_hba_line(Tuplestorestate *tuple_store, TupleDesc tupdesc, /* * fill_hba_view - * Read the pg_hba.conf file and fill the tuplestore with view records. + * Read the hba configuration and fill the tuplestore with view records. + * + * The rules come from hba_file, in either format. */ static void fill_hba_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) { - FILE *file; + bool use_json = (config_json_auth_source(HbaFileName) != NULL); + FILE *file = NULL; List *hba_lines = NIL; ListCell *line; int rule_number = 0; @@ -383,14 +397,46 @@ fill_hba_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) MemoryContext oldcxt; /* - * In the unlikely event that we can't open pg_hba.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(HbaFileName, ERROR, 0, NULL); + if (use_json) + { + List *documents; + char *file_err; + + /* + * Only tokenize_hba_json() creates the contexts that + * free_json_section_tokens() releases; after a failed load of the + * documents there is nothing of ours to free, and the statics may + * still point at memory an earlier, aborted call has lost to the + * per-query reset. + */ + if (!config_json_load_section("hba", HbaFileName, DEBUG3, + &documents, &file_err)) + ereport(ERROR, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("could not load configuration file \"%s\"", + HbaFileName), + errdetail_internal("%s", file_err))); + if (!tokenize_hba_json(documents, &hba_lines, DEBUG3, &file_err)) + { + free_json_section_tokens(); + ereport(ERROR, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg("could not load configuration file \"%s\"", + HbaFileName), + errdetail_internal("%s", file_err))); + } + } + else + { + file = open_auth_file(HbaFileName, ERROR, 0, NULL); - tokenize_auth_file(HbaFileName, file, &hba_lines, DEBUG3, 0); + tokenize_auth_file(HbaFileName, file, &hba_lines, DEBUG3, 0); + } /* Now parse all the lines */ hbacxt = AllocSetContextCreate(CurrentMemoryContext, @@ -411,12 +457,15 @@ fill_hba_view(Tuplestorestate *tuple_store, TupleDesc tupdesc) rule_number++; fill_hba_line(tuple_store, tupdesc, rule_number, - tok_line->file_name, tok_line->line_num, hbaline, - tok_line->err_msg); + tok_line->file_name, tok_line->line_num, + tok_line->node_path, hbaline, tok_line->err_msg); } /* Free tokenizer memory */ - free_auth_file(file, 0); + if (use_json) + free_json_section_tokens(); + else + free_auth_file(file, 0); /* Free parse_hba_line memory */ MemoryContextSwitchTo(oldcxt); MemoryContextDelete(hbacxt); diff --git a/src/backend/utils/misc/Makefile b/src/backend/utils/misc/Makefile index f142d17178b..439c4436829 100644 --- a/src/backend/utils/misc/Makefile +++ b/src/backend/utils/misc/Makefile @@ -16,6 +16,7 @@ override CPPFLAGS := -I. -I$(srcdir) $(CPPFLAGS) OBJS = \ conffiles.o \ + config_json.o \ guc.o \ guc-file.o \ guc_funcs.o \ diff --git a/src/backend/utils/misc/conffiles.c b/src/backend/utils/misc/conffiles.c index c77ff4d38d4..c58e181dcf2 100644 --- a/src/backend/utils/misc/conffiles.c +++ b/src/backend/utils/misc/conffiles.c @@ -59,6 +59,49 @@ AbsoluteConfigLocation(const char *location, const char *calling_file) } +/* + * Match a file name against a pattern with "*" (any run of characters, + * including none) and "?" (any one character). There is no portable + * fnmatch(), and character classes are more than configuration includes + * need. Recursion is one level per "*" in the pattern, which is fine for + * directory entry names. + */ +static bool +wildcard_match(const char *pattern, const char *name) +{ + for (;;) + { + switch (*pattern) + { + case '\0': + return *name == '\0'; + case '?': + if (*name == '\0') + return false; + pattern++; + name++; + break; + case '*': + while (*pattern == '*') + pattern++; + if (*pattern == '\0') + return true; + for (; *name != '\0'; name++) + { + if (wildcard_match(pattern, name)) + return true; + } + return false; + default: + if (*pattern != *name) + return false; + pattern++; + name++; + break; + } + } +} + /* * GetConfFilesInDir * @@ -69,6 +112,21 @@ AbsoluteConfigLocation(const char *location, const char *calling_file) char ** GetConfFilesInDir(const char *includedir, const char *calling_file, int elevel, int *num_filenames, char **err_msg) +{ + return GetConfFilesInDirPattern(includedir, calling_file, "*.conf", + elevel, num_filenames, err_msg); +} + +/* + * GetConfFilesInDirPattern + * + * Like GetConfFilesInDir, but the caller chooses which file names count, + * as a wildcard pattern matched against the bare file name. + */ +char ** +GetConfFilesInDirPattern(const char *includedir, const char *calling_file, + const char *pattern, int elevel, + int *num_filenames, char **err_msg) { char *directory; DIR *d; @@ -117,15 +175,13 @@ GetConfFilesInDir(const char *includedir, const char *calling_file, char filename[MAXPGPATH]; /* - * Only parse files with names ending in ".conf". Explicitly reject + * Only parse files whose names match the pattern. Explicitly reject * files starting with ".". This excludes things like "." and "..", * as well as typical hidden files, backup files, and editor debris. */ - if (strlen(de->d_name) < 6) - continue; if (de->d_name[0] == '.') continue; - if (strcmp(de->d_name + strlen(de->d_name) - 5, ".conf") != 0) + if (!wildcard_match(pattern, de->d_name)) continue; join_path_components(filename, directory, de->d_name); diff --git a/src/backend/utils/misc/config_json.c b/src/backend/utils/misc/config_json.c new file mode 100644 index 00000000000..ec5068dc74d --- /dev/null +++ b/src/backend/utils/misc/config_json.c @@ -0,0 +1,958 @@ +/*------------------------------------------------------------------------- + * + * config_json.c + * Loader for JSON-format configuration files. + * + * A JSON client authentication file, named by hba_file, ident_file or + * hosts_file in place of the legacy file, is one object holding a single + * section ("hba", "ident" or "hosts") and, optionally, an "include" key + * naming further files like it. We parse the document with the common + * JSON parser into a small tree that keeps key order and source + * positions, resolve includes, and hand back the documents in load order, + * so that the loader of the section can pick out its parts. + * + * Errors about a value carry the value's location as error context: its + * path in the document, such as $.include[1] or $["pg_stat_statements.max"], + * and the file and line. Where the message has to be one string, as in + * the views, the location is appended to it in parentheses. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * IDENTIFICATION + * src/backend/utils/misc/config_json.c + * + *------------------------------------------------------------------------- + */ +#include "postgres.h" + +#include + +#include "common/jsonapi.h" +#include "guc_internal.h" +#include "lib/stringinfo.h" +#include "mb/pg_wchar.h" +#include "storage/fd.h" +#include "utils/conffiles.h" +#include "utils/config_json.h" +#include "utils/guc_tables.h" +#include "utils/json.h" + +/* + * State of the tree builder while pg_parse_json walks one document. + */ +typedef struct JsonConfParseState +{ + JsonLexContext *lex; + const char *file; + JsonConfNode *root; + JsonConfNode *current; /* innermost open container */ + int pending_line; /* line captured at field/element start */ + char *err; /* set on semantic failure */ + char *err_path; /* where, for a semantic failure */ +} JsonConfParseState; + +static bool load_document(const char *path, const char *calling_file, + bool strict, int depth, int elevel, + const char *section, + ConfigVariable **head_p, ConfigVariable **tail_p, + List **documents_p, JsonConfNode *directive); +static void append_path_key(StringInfo buf, const char *key); + + +/* + * Tree builder callbacks for pg_parse_json. + * + * The lexer's line_number is the line of the token it has most recently + * read. When object_field_start or array_element_start fires, that is the + * first token of the value, which is what we want to remember for the + * node. By the time the scalar callback fires the lexer has moved on, so + * the line is taken from pending_line instead. The root container has no + * such hint and uses the current line. + */ +static JsonConfNode * +new_node(JsonConfParseState *st, JsonConfNodeType type) +{ + JsonConfNode *n = palloc0_object(JsonConfNode); + + n->type = type; + n->file = st->file; + n->line = st->pending_line ? st->pending_line : st->lex->line_number; + n->index = -1; + return n; +} + +/* Attach a freshly made node under the current container. */ +static void +attach_node(JsonConfParseState *st, JsonConfNode *n) +{ + JsonConfNode *parent = st->current; + + if (parent == NULL) + { + st->root = n; + return; + } + n->parent = parent; + if (parent->type == JCN_OBJECT) + { + /* key was pushed by jc_object_field_start */ + n->key = llast(parent->keys); + } + else + n->index = list_length(parent->values); + parent->values = lappend(parent->values, n); +} + +static JsonParseErrorType +jc_object_start(void *state) +{ + JsonConfParseState *st = state; + JsonConfNode *n = new_node(st, JCN_OBJECT); + + attach_node(st, n); + st->current = n; + st->pending_line = 0; + return JSON_SUCCESS; +} + +static JsonParseErrorType +jc_array_start(void *state) +{ + JsonConfParseState *st = state; + JsonConfNode *n = new_node(st, JCN_ARRAY); + + attach_node(st, n); + st->current = n; + st->pending_line = 0; + return JSON_SUCCESS; +} + +static JsonParseErrorType +jc_container_end(void *state) +{ + JsonConfParseState *st = state; + + st->current = st->current->parent; + return JSON_SUCCESS; +} + +static JsonParseErrorType +jc_object_field_start(void *state, char *fname, bool isnull) +{ + JsonConfParseState *st = state; + JsonConfNode *obj = st->current; + ListCell *lc; + + /* + * JSON itself allows repeated keys, with unspecified meaning. For a + * configuration file silently taking either occurrence would hide + * mistakes, so refuse. + */ + foreach(lc, obj->keys) + { + if (strcmp((char *) lfirst(lc), fname) == 0) + { + StringInfoData path; + + initStringInfo(&path); + appendStringInfoString(&path, config_json_path(obj)); + append_path_key(&path, fname); + st->err = psprintf(_("duplicate key \"%s\""), fname); + st->err_path = path.data; + return JSON_SEM_ACTION_FAILED; + } + } + obj->keys = lappend(obj->keys, fname); + st->pending_line = st->lex->line_number; + return JSON_SUCCESS; +} + +static JsonParseErrorType +jc_array_element_start(void *state, bool isnull) +{ + JsonConfParseState *st = state; + + st->pending_line = st->lex->line_number; + return JSON_SUCCESS; +} + +static JsonParseErrorType +jc_scalar(void *state, char *token, JsonTokenType tokentype) +{ + JsonConfParseState *st = state; + JsonConfNode *n; + + switch (tokentype) + { + case JSON_TOKEN_STRING: + n = new_node(st, JCN_STRING); + n->strval = token; + break; + case JSON_TOKEN_NUMBER: + n = new_node(st, JCN_NUMBER); + n->strval = token; + break; + case JSON_TOKEN_TRUE: + case JSON_TOKEN_FALSE: + n = new_node(st, JCN_BOOL); + n->boolval = (tokentype == JSON_TOKEN_TRUE); + break; + default: + n = new_node(st, JCN_NULL); + break; + } + st->pending_line = 0; + attach_node(st, n); + return JSON_SUCCESS; +} + +/* + * Look for \u escapes for characters above U+007F, which we reject. + * + * The JSON lexer converts such escapes to the server encoding, which is + * SQL_ASCII in the postmaster but the database encoding in a backend, so + * the same file could be refused at startup yet accepted on reload. The + * file itself is UTF-8, so the character can always be written directly + * instead. Returns the line of the first such escape, or 0 if there is + * none. Malformed escapes are left for the lexer to report. + */ +static int +check_unicode_escapes(const char *data) +{ + int line = 1; + bool in_string = false; + + for (const char *p = data; *p != '\0'; p++) + { + if (*p == '\n') + line++; + else if (!in_string) + { + if (*p == '"') + in_string = true; + } + else if (*p == '"') + in_string = false; + else if (*p == '\\') + { + if (p[1] == 'u') + { + int cp = 0; + int i; + + for (i = 2; i <= 5; i++) + { + int c = (unsigned char) p[i]; + + if (c >= '0' && c <= '9') + cp = cp * 16 + (c - '0'); + else if (c >= 'a' && c <= 'f') + cp = cp * 16 + (c - 'a' + 10); + else if (c >= 'A' && c <= 'F') + cp = cp * 16 + (c - 'A' + 10); + else + break; + } + if (i > 5 && cp > 0x7F) + return line; + } + /* skip the escaped character, so an escaped quote is not a close */ + if (p[1] != '\0') + p++; + } + } + return 0; +} + +/* + * Parse one file into a tree. Returns NULL and sets *err_msg on failure, + * with *err_line the line it is about (0 if none), *err_path the location + * in the document (NULL if none) and *err_hint a hint (NULL if none). A + * file that cannot be opened is reported with *err_msg == NULL and errno + * set, leaving it to the caller to decide whether that is an error. + */ +static JsonConfNode * +parse_json_file(const char *path, char **err_msg, int *err_line, + char **err_path, const char **err_hint) +{ + FILE *fp; + StringInfoData buf; + char chunk[8192]; + size_t nread; + JsonConfParseState st; + JsonSemAction sem; + JsonLexContext *lex; + JsonParseErrorType res; + int line; + + *err_msg = NULL; + *err_line = 0; + *err_path = NULL; + *err_hint = NULL; + fp = AllocateFile(path, "r"); + if (fp == NULL) + return NULL; + initStringInfo(&buf); + while ((nread = fread(chunk, 1, sizeof(chunk), fp)) > 0) + appendBinaryStringInfo(&buf, chunk, nread); + if (ferror(fp)) + { + *err_msg = psprintf(_("could not read file \"%s\": %m"), path); + FreeFile(fp); + return NULL; + } + FreeFile(fp); + + line = check_unicode_escapes(buf.data); + if (line != 0) + { + *err_msg = pstrdup(_("Unicode escape sequences above U+007F are not supported")); + *err_line = line; + *err_hint = _("Write the character in UTF-8 instead."); + return NULL; + } + + memset(&st, 0, sizeof(st)); + st.file = path; + /* need_escapes, or the callbacks would not see the key and value text */ + lex = makeJsonLexContextCstringLen(NULL, buf.data, buf.len, PG_UTF8, true); + st.lex = lex; + + memset(&sem, 0, sizeof(sem)); + sem.semstate = &st; + sem.object_start = jc_object_start; + sem.object_end = jc_container_end; + sem.array_start = jc_array_start; + sem.array_end = jc_container_end; + sem.object_field_start = jc_object_field_start; + sem.array_element_start = jc_array_element_start; + sem.scalar = jc_scalar; + + res = pg_parse_json(lex, &sem); + if (res != JSON_SUCCESS) + { + if (st.err) + { + *err_msg = st.err; + *err_path = st.err_path; + } + else + *err_msg = psprintf(_("invalid JSON: %s"), + json_errdetail(res, lex)); + *err_line = lex->line_number; + freeJsonLexContext(lex); + return NULL; + } + freeJsonLexContext(lex); + + if (st.root == NULL || st.root->type != JCN_OBJECT) + { + *err_msg = pstrdup(_("the top level must be a JSON object")); + return NULL; + } + return st.root; +} + + +/* + * Public helpers for walking a parsed document. + */ + +/* Is this file name one we treat as a JSON configuration file? */ +bool +config_is_json(const char *filename) +{ + const char *dot = strrchr(filename, '.'); + + return dot != NULL && strcmp(dot, ".json") == 0; +} + +/* Look up a key in an object node; NULL if absent or not an object. */ +JsonConfNode * +config_json_get(JsonConfNode *obj, const char *key) +{ + ListCell *k, + *v; + + if (obj == NULL || obj->type != JCN_OBJECT) + return NULL; + forboth(k, obj->keys, v, obj->values) + { + if (strcmp((char *) lfirst(k), key) == 0) + return (JsonConfNode *) lfirst(v); + } + return NULL; +} + +/* + * Append one key to a path: ".key" for a key that looks like an + * identifier, else the bracket form with the key as a JSON string, so that + * a key with a dot in it, as in a custom parameter name, stays one step. + */ +static void +append_path_key(StringInfo buf, const char *key) +{ + bool plain = (key[0] != '\0' && !isdigit((unsigned char) key[0])); + + for (const char *p = key; *p != '\0' && plain; p++) + { + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '_')) + plain = false; + } + if (plain) + appendStringInfo(buf, ".%s", key); + else + { + appendStringInfoChar(buf, '['); + escape_json(buf, key); + appendStringInfoChar(buf, ']'); + } +} + +/* + * Path of a node from the document root, such as "$.hba.children[2]" or + * "$[\"pg_stat_statements.max\"]". + */ +char * +config_json_path(JsonConfNode *node) +{ + StringInfoData buf; + List *chain = NIL; + ListCell *lc; + + for (; node != NULL; node = node->parent) + chain = lcons(node, chain); + initStringInfo(&buf); + appendStringInfoChar(&buf, '$'); + foreach(lc, chain) + { + JsonConfNode *n = lfirst(lc); + + if (n->key != NULL) + append_path_key(&buf, n->key); + else if (n->index >= 0) + appendStringInfo(&buf, "[%d]", n->index); + } + return buf.data; +} + +const char * +config_json_type_name(JsonConfNodeType type) +{ + switch (type) + { + case JCN_OBJECT: + return "object"; + case JCN_ARRAY: + return "array"; + case JCN_STRING: + return "string"; + case JCN_NUMBER: + return "number"; + case JCN_BOOL: + return "boolean"; + case JCN_NULL: + return "null"; + } + return "unknown"; +} + +/* + * Error context for a message about a node: its path, file and line. For + * use inside ereport(). + */ +int +config_json_errcontext(JsonConfNode *node) +{ + return errcontext("node %s, line %d of configuration file \"%s\"", + config_json_path(node), node->line, node->file); +} + +/* + * A message about a node with its location appended, for where the message + * has to stand alone, such as the error column of a view. + */ +char * +config_json_error_text(JsonConfNode *node, const char *msg) +{ + return psprintf(_("%s (node %s, line %d of file \"%s\")"), + msg, config_json_path(node), node->line, node->file); +} + +/* GUC text for a scalar: string as is, number as lexeme, bool as on/off. */ +char * +config_json_scalar_text(JsonConfNode *node) +{ + switch (node->type) + { + case JCN_STRING: + case JCN_NUMBER: + return node->strval; + case JCN_BOOL: + return node->boolval ? "on" : "off"; + default: + return NULL; + } +} + + +/* + * Error reporting. + * + * Problems are reported at elevel, and recorded as ConfigVariable error + * items in the shape ParseConfigFp produces for a broken file, so that + * the caller can take the first message out of the list. + */ + +/* + * Report a problem with a node at elevel, with the node's location as + * context, and record it with the location appended. The message is + * translated here. + */ +static void json_setting_error(JsonConfNode *node, int elevel, + ConfigVariable **head_p, + ConfigVariable **tail_p, + const char *fmt, ...) pg_attribute_printf(5, 6); + +static void +json_setting_error(JsonConfNode *node, int elevel, ConfigVariable **head_p, + ConfigVariable **tail_p, const char *fmt, ...) +{ + StringInfoData msg; + + fmt = _(fmt); + initStringInfo(&msg); + for (;;) + { + va_list args; + int needed; + + va_start(args, fmt); + needed = appendStringInfoVA(&msg, fmt, args); + va_end(args); + if (needed == 0) + break; + enlargeStringInfo(&msg, needed); + } + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", msg.data), + config_json_errcontext(node))); + record_config_file_error(config_json_error_text(node, msg.data), + node->file, node->line, head_p, tail_p); +} + +/* + * Report a problem that has no node to blame, such as a file that does not + * parse, at elevel, and record it with the location appended. "line" may + * be 0 if there is none. + */ +static void +json_file_error(const char *file, int line, int elevel, const char *msg, + const char *hint, ConfigVariable **head_p, + ConfigVariable **tail_p) +{ + char *text; + + if (line > 0) + { + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", msg), + hint ? errhint_internal("%s", hint) : 0, + errcontext("line %d of configuration file \"%s\"", + line, file))); + text = psprintf(_("%s (line %d of file \"%s\")"), msg, line, file); + } + else + { + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", msg), + hint ? errhint_internal("%s", hint) : 0, + errcontext("configuration file \"%s\"", file))); + text = psprintf(_("%s (file \"%s\")"), msg, file); + } + record_config_file_error(text, file, line, head_p, tail_p); +} + +/* + * Process one include entry: a string, or an object with "file" and an + * optional "optional" flag. The file name may carry wildcards in its last + * component, in which case all matching files are loaded in name order and + * no match at all is fine. + */ +static bool +process_include_entry(JsonConfNode *entry, const char *calling_file, + int depth, int elevel, const char *section, + ConfigVariable **head_p, ConfigVariable **tail_p, + List **documents_p) +{ + const char *spec; + bool optional = false; + char *abs; + + if (entry->type == JCN_STRING) + spec = entry->strval; + else if (entry->type == JCN_OBJECT) + { + JsonConfNode *f = config_json_get(entry, "file"); + JsonConfNode *o = config_json_get(entry, "optional"); + ListCell *k, + *v; + + forboth(k, entry->keys, v, entry->values) + { + const char *key = lfirst(k); + + if (strcmp(key, "file") != 0 && strcmp(key, "optional") != 0) + { + json_setting_error(lfirst(v), elevel, head_p, tail_p, + "unrecognized key \"%s\"", key); + return false; + } + } + if (f == NULL) + { + json_setting_error(entry, elevel, head_p, tail_p, + "missing required key \"%s\"", "file"); + return false; + } + if (f->type != JCN_STRING) + { + json_setting_error(f, elevel, head_p, tail_p, + "\"%s\" must be a string", "file"); + return false; + } + if (o != NULL) + { + if (o->type != JCN_BOOL) + { + json_setting_error(o, elevel, head_p, tail_p, + "\"%s\" must be a boolean", "optional"); + return false; + } + optional = o->boolval; + } + spec = f->strval; + } + else + { + json_setting_error(entry, elevel, head_p, tail_p, + "\"%s\" must be a string, an object, or an array of these", + "include"); + return false; + } + + if (spec[0] == '\0') + { + json_setting_error(entry, elevel, head_p, tail_p, + "empty include file name"); + return false; + } + + /* + * Look for wildcards in the name as written, not in the resolved path, + * since the directories it is resolved against may contain such + * characters legitimately. + */ + if (strpbrk(spec, "*?") != NULL) + { + char *dirspec = pstrdup(spec); + char *pattern; + char *dir; + char **names; + int nnames; + char *err = NULL; + struct stat st; + bool ok = true; + + pattern = last_dir_separator(dirspec); + if (pattern == NULL) + { + /* bare pattern: relative to the including file's directory */ + pattern = dirspec; + dirspec = "."; + } + else + { + *pattern++ = '\0'; + if (dirspec[0] == '\0') + dirspec = "/"; + } + if (strpbrk(dirspec, "*?") != NULL) + { + json_setting_error(entry, elevel, head_p, tail_p, + "wildcards are only allowed in the file name part: \"%s\"", + spec); + return false; + } + dir = AbsoluteConfigLocation(dirspec, calling_file); + + /* + * An optional pattern whose directory does not exist is the same as + * matching nothing. Check that ourselves, since the directory + * scanner would raise an error at elevel, which is fatal at startup. + */ + if (optional && stat(dir, &st) != 0 && errno == ENOENT) + { + ereport(LOG, + (errmsg("skipping missing configuration directory \"%s\"", + dir))); + return true; + } + + names = GetConfFilesInDirPattern(dir, calling_file, pattern, elevel, + &nnames, &err); + if (names == NULL) + { + /* already reported at elevel; just record it for the view */ + record_config_file_error(err, entry->file, entry->line, + head_p, tail_p); + return false; + } + + /* + * A pattern that matches nothing is treated like a missing file: + * silently reading nothing could drop a whole set of rules, such as + * a directory of authentication rejects, when its last file goes + * away. + */ + if (nnames == 0 && !optional) + { + json_setting_error(entry, elevel, head_p, tail_p, + "include pattern \"%s\" matches no files", + spec); + return false; + } + for (int i = 0; i < nnames; i++) + { + if (!load_document(names[i], calling_file, true, depth + 1, elevel, + section, head_p, tail_p, documents_p, entry)) + ok = false; + } + return ok; + } + + abs = AbsoluteConfigLocation(spec, calling_file); + return load_document(abs, calling_file, !optional, depth + 1, elevel, + section, head_p, tail_p, documents_p, entry); +} + +/* + * Load one document and everything it includes, appending them to + * *documents_p in that order: the document first, then its includes. + * The document may hold the given section and an "include" key, nothing + * else. + */ +static bool +load_document(const char *path, const char *calling_file, bool strict, + int depth, int elevel, const char *section, + ConfigVariable **head_p, ConfigVariable **tail_p, + List **documents_p, JsonConfNode *directive) +{ + JsonConfNode *doc; + char *err = NULL; + int err_line; + char *err_path; + const char *err_hint; + ListCell *k, + *v; + bool ok = true; + JsonConfNode *include; + + if (depth > CONF_FILE_MAX_DEPTH) + { + ereport(elevel, + (errcode(ERRCODE_PROGRAM_LIMIT_EXCEEDED), + errmsg("could not open configuration file \"%s\": maximum nesting depth exceeded", + path))); + record_config_file_error("nesting depth exceeded", calling_file, + directive ? directive->line : 0, head_p, tail_p); + return false; + } + + /* + * An optional file may be missing, but not unreadable. As for + * include_if_exists in pg_hba.conf, any other failure to open it is an + * error: silently dropping a file of rules that merely has the wrong + * permissions would change what the remaining rules mean. + */ + if (!strict) + { + FILE *fp = AllocateFile(path, "r"); + + if (fp == NULL) + { + if (errno == ENOENT) + { + ereport(LOG, + (errmsg("skipping missing configuration file \"%s\"", + path))); + return true; + } + ereport(elevel, + (errcode_for_file_access(), + errmsg("could not open configuration file \"%s\": %m", + path))); + record_config_file_error(psprintf("could not open file \"%s\"", path), + directive ? directive->file : path, + directive ? directive->line : 0, + head_p, tail_p); + return false; + } + FreeFile(fp); + } + + /* Only documents can be included; the root is known to be one. */ + if (!config_is_json(path)) + { + json_setting_error(directive, elevel, head_p, tail_p, + "included file \"%s\" is not a JSON file", path); + return false; + } + + /* + * Reject direct recursion, which a wildcard matching the including file + * makes easy to write. Indirect recursion is caught by the depth limit. + */ + if (calling_file != NULL && strcmp(path, calling_file) == 0) + { + ereport(elevel, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("configuration file recursion in \"%s\"", + calling_file))); + record_config_file_error("configuration file recursion", calling_file, + directive ? directive->line : 0, head_p, tail_p); + return false; + } + + doc = parse_json_file(path, &err, &err_line, &err_path, &err_hint); + if (doc == NULL) + { + if (err == NULL) + { + /* could not open it; blame the directive, if there is one */ + ereport(elevel, + (errcode_for_file_access(), + errmsg("could not open configuration file \"%s\": %m", + path))); + record_config_file_error(psprintf("could not open file \"%s\"", path), + directive ? directive->file : path, + directive ? directive->line : 0, + head_p, tail_p); + return false; + } + if (err_path != NULL) + { + ereport(elevel, + (errcode(ERRCODE_CONFIG_FILE_ERROR), + errmsg_internal("%s", err), + errcontext("node %s, line %d of configuration file \"%s\"", + err_path, err_line, path))); + record_config_file_error(psprintf(_("%s (node %s, line %d of file \"%s\")"), + err, err_path, err_line, path), + path, err_line, head_p, tail_p); + } + else + json_file_error(path, err_line, elevel, err, err_hint, + head_p, tail_p); + return false; + } + + *documents_p = lappend(*documents_p, doc); + + forboth(k, doc->keys, v, doc->values) + { + const char *key = lfirst(k); + + /* a client authentication document holds one section */ + if (strcmp(key, "include") != 0 && strcmp(key, section) != 0) + { + json_setting_error(lfirst(v), elevel, head_p, tail_p, + "unrecognized key \"%s\"", key); + ok = false; + } + } + + include = config_json_get(doc, "include"); + if (include != NULL) + { + if (include->type == JCN_STRING || include->type == JCN_OBJECT) + { + if (!process_include_entry(include, path, depth, elevel, section, + head_p, tail_p, documents_p)) + ok = false; + } + else if (include->type == JCN_ARRAY) + { + ListCell *lc; + + foreach(lc, include->values) + { + if (!process_include_entry(lfirst(lc), path, depth, elevel, + section, head_p, tail_p, + documents_p)) + ok = false; + } + } + else + { + json_setting_error(include, elevel, head_p, tail_p, + "\"%s\" must be a string, an object, or an array of these", + "include"); + ok = false; + } + } + return ok; +} + +/* + * Load the documents of a client authentication file, the file first and + * then its includes in load order, for the loader of the section, which + * picks out its parts. On failure *err_msg carries the first recorded + * error text. + */ +bool +config_json_load_section(const char *section, const char *filename, + int elevel, List **documents_p, char **err_msg) +{ + ConfigVariable *head = NULL, + *tail = NULL; + char *abs = AbsoluteConfigLocation(filename, NULL); + bool ok; + + *err_msg = NULL; + *documents_p = NIL; + ok = load_document(abs, NULL, true, CONF_FILE_START_DEPTH, elevel, + section, &head, &tail, documents_p, NULL); + if (!ok) + { + for (ConfigVariable *item = head; item; item = item->next) + if (item->errmsg) + { + *err_msg = pstrdup(item->errmsg); + break; + } + if (*err_msg == NULL) + *err_msg = psprintf(_("could not load configuration file \"%s\""), + filename); + } + FreeConfigVariables(head); + return ok; +} + +/* + * Which JSON document holds a client authentication section, given the + * section's file parameter: the file itself when its name says it is + * JSON, or NULL for the legacy format. + */ +const char * +config_json_auth_source(const char *filename) +{ + if (filename != NULL && config_is_json(filename)) + return filename; + return NULL; +} diff --git a/src/backend/utils/misc/meson.build b/src/backend/utils/misc/meson.build index 232e74d0af9..cfef3c8edf1 100644 --- a/src/backend/utils/misc/meson.build +++ b/src/backend/utils/misc/meson.build @@ -2,6 +2,7 @@ backend_sources += files( 'conffiles.c', + 'config_json.c', 'guc.c', 'guc_funcs.c', 'guc_tables.c', diff --git a/src/include/catalog/pg_proc.dat b/src/include/catalog/pg_proc.dat index f46427258e3..9718218a3b6 100644 --- a/src/include/catalog/pg_proc.dat +++ b/src/include/catalog/pg_proc.dat @@ -6669,12 +6669,12 @@ proargmodes => '{o,o,o,o,o,o,o}', proargnames => '{sourcefile,sourceline,seqno,name,setting,applied,error}', prosrc => 'show_all_file_settings', proacl => '{POSTGRES=X}' }, -{ oid => '3401', descr => 'show pg_hba.conf rules', +{ oid => '3401', descr => 'show client authentication rules', proname => 'pg_hba_file_rules', prorows => '1000', proretset => 't', provolatile => 'v', prorettype => 'record', proargtypes => '', - proallargtypes => '{int4,text,int4,text,_text,_text,text,text,text,_text,text}', - proargmodes => '{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,error}', + proallargtypes => '{int4,text,int4,text,_text,_text,text,text,text,_text,text,text}', + 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', proname => 'pg_ident_file_mappings', prorows => '1000', proretset => 't', diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h index 4aa6258a345..37e133281b7 100644 --- a/src/include/libpq/hba.h +++ b/src/include/libpq/hba.h @@ -133,6 +133,8 @@ typedef struct HbaLine bool oauth_skip_usermap; List *oauth_opt_keys; List *oauth_opt_vals; + char *node_path; /* JSON node this rule was compiled from, or + * NULL for a legacy line */ } HbaLine; typedef struct IdentLine @@ -181,6 +183,8 @@ typedef struct TokenizedAuthLine int line_num; /* Line number */ char *raw_line; /* Raw line text */ char *err_msg; /* Error message if any */ + char *node_path; /* JSON node this rule was compiled from, or + * NULL for a legacy line */ } TokenizedAuthLine; /* avoid including libpq/libpq-be.h here */ @@ -200,5 +204,20 @@ extern FILE *open_auth_file(const char *filename, int elevel, int depth, extern void free_auth_file(FILE *file, int depth); extern void tokenize_auth_file(const char *filename, FILE *file, List **tok_lines, int elevel, int depth); +extern void hba_tokenize_context_create(void); +extern void hba_tokenize_context_delete(void); +extern AuthToken *make_auth_token(const char *token, bool quoted); +extern List *hba_expand_auth_file(List *tokens, const char *outer_filename, + const char *inc_filename, int elevel, + char **err_msg); + +/* in hba_json.c */ +struct JsonConfNode; +extern bool tokenize_hba_json(List *documents, List **tok_lines, int elevel, + char **file_err); +extern void free_json_section_tokens(void); +extern bool section_shape_error(struct JsonConfNode *node, int elevel, + char **file_err, const char *fmt, ...) + pg_attribute_printf(4, 5); #endif /* HBA_H */ diff --git a/src/include/utils/conffiles.h b/src/include/utils/conffiles.h index 1548cd70bfa..d17d2ede198 100644 --- a/src/include/utils/conffiles.h +++ b/src/include/utils/conffiles.h @@ -23,5 +23,10 @@ extern char **GetConfFilesInDir(const char *includedir, const char *calling_file, int elevel, int *num_filenames, char **err_msg); +extern char **GetConfFilesInDirPattern(const char *includedir, + const char *calling_file, + const char *pattern, + int elevel, int *num_filenames, + char **err_msg); #endif /* CONFFILES_H */ diff --git a/src/include/utils/config_json.h b/src/include/utils/config_json.h new file mode 100644 index 00000000000..ffdfab1b896 --- /dev/null +++ b/src/include/utils/config_json.h @@ -0,0 +1,63 @@ +/*------------------------------------------------------------------------- + * + * config_json.h + * JSON configuration file support + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/include/utils/config_json.h + * + *------------------------------------------------------------------------- + */ +#ifndef CONFIG_JSON_H +#define CONFIG_JSON_H + +#include "nodes/pg_list.h" +#include "utils/guc.h" + +typedef enum JsonConfNodeType +{ + JCN_OBJECT, + JCN_ARRAY, + JCN_STRING, + JCN_NUMBER, + JCN_BOOL, + JCN_NULL, +} JsonConfNodeType; + +/* + * One node of a parsed JSON configuration document. Objects keep their + * keys in document order (keys and values are parallel lists), arrays keep + * element order. Every node remembers where it came from, so that errors + * found long after parsing can still point at a file and line. + */ +typedef struct JsonConfNode +{ + JsonConfNodeType type; + const char *file; /* absolute path of the source file */ + int line; /* 1-based line of the value */ + char *strval; /* JCN_STRING: text; JCN_NUMBER: raw lexeme */ + bool boolval; /* JCN_BOOL */ + List *keys; /* JCN_OBJECT: List of char * */ + List *values; /* JCN_OBJECT, JCN_ARRAY: List of JsonConfNode * */ + struct JsonConfNode *parent; + const char *key; /* key in parent object, or NULL */ + int index; /* index in parent array, or -1 */ +} JsonConfNode; + +extern bool config_is_json(const char *filename); + +extern const char *config_json_auth_source(const char *filename); +extern bool config_json_load_section(const char *section, + const char *filename, int elevel, + List **documents_p, char **err_msg); + +extern JsonConfNode *config_json_get(JsonConfNode *obj, const char *key); +extern char *config_json_path(JsonConfNode *node); +extern int config_json_errcontext(JsonConfNode *node); +extern char *config_json_error_text(JsonConfNode *node, const char *msg); +extern const char *config_json_type_name(JsonConfNodeType type); +extern char *config_json_scalar_text(JsonConfNode *node); + +#endif /* CONFIG_JSON_H */ diff --git a/src/test/authentication/meson.build b/src/test/authentication/meson.build index 282a5054e2c..6f4d557d693 100644 --- a/src/test/authentication/meson.build +++ b/src/test/authentication/meson.build @@ -16,6 +16,7 @@ tests += { 't/005_sspi.pl', 't/006_login_trigger.pl', 't/007_pre_auth.pl', + 't/008_json_hba.pl', ], }, } diff --git a/src/test/authentication/t/008_json_hba.pl b/src/test/authentication/t/008_json_hba.pl new file mode 100644 index 00000000000..312446dda3b --- /dev/null +++ b/src/test/authentication/t/008_json_hba.pl @@ -0,0 +1,433 @@ +# Copyright (c) 2026, PostgreSQL Global Development Group + +# Test the JSON format of the client authentication rules, in the file +# named by hba_file. + +use strict; +use warnings FATAL => 'all'; +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; +use Test::More; + +if (!$use_unix_sockets) +{ + plan skip_all => "authentication tests cannot run without Unix-domain sockets"; +} + +my $node = PostgreSQL::Test::Cluster->new('json_hba'); +$node->init; +my $datadir = $node->data_dir; +my $hba_json = "$datadir/pg_hba.json"; + +sub write_file +{ + my ($path, $content) = @_; + open my $fh, '>', $path or die "cannot write $path: $!"; + print $fh $content; + close $fh; +} + +# $hba is the JSON text of the "hba" value. +sub set_hba +{ + my ($hba) = @_; + write_file($hba_json, qq({"hba": $hba}\n)); +} + +$node->append_conf('postgresql.conf', <<"EOF"); +listen_addresses = '127.0.0.1' +hba_file = '$hba_json' +EOF +set_hba( + '{"children": [{"transport": "local", "auth": {"method": "trust"}}]}'); +$node->start; + +# With EXEC_BACKEND, each new backend loads the rules from the files +# itself, rather than inheriting what the postmaster loaded. +my $exec_backend = $node->safe_psql('postgres', 'SHOW debug_exec_backend'); + +$node->safe_psql( + 'postgres', q{ + SET password_encryption = 'scram-sha-256'; + CREATE ROLE alice LOGIN PASSWORD 'pass'; + CREATE ROLE bob LOGIN PASSWORD 'pass'; + CREATE ROLE jacob LOGIN PASSWORD 'pass'; + CREATE ROLE admins NOLOGIN; + GRANT admins TO bob; + CREATE ROLE "all" LOGIN PASSWORD 'pass'; +}); + +# The three-level example from the proposal, with scram standing in for +# LDAP so it can be tested without an LDAP server: everyone scram, +# admins additionally need a client certificate, jacob is rejected. +# clientcert needs an SSL rule, which this cluster cannot match, so the +# admins get a different method instead. +set_hba(<<'EOF'); +{"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"address": "127.0.0.1/32", + "auth": {"method": "scram-sha-256"}, + "children": [ + {"users": "+admins", "auth": {"method": "trust"}}, + {"users": "jacob", "auth": {"method": "reject"}} + ]} +]} +EOF +$node->reload; + +my $res = $node->safe_psql( + 'postgres', q{ + SELECT rule_number, type, user_name, auth_method, node_path, + error IS NULL + FROM pg_hba_file_rules ORDER BY rule_number}); +is( $res, + join( + "\n", + '1|local|{all}|trust|$.hba.children[0]|t', + '2|host|{+admins}|trust|$.hba.children[1].children[0]|t', + '3|host|{jacob}|reject|$.hba.children[1].children[1]|t', + '4|host|{all}|scram-sha-256|$.hba.children[1]|t'), + 'tree compiled depth-first, children before own rule'); + +$ENV{PGPASSWORD} = 'pass'; +$node->connect_ok('host=127.0.0.1 dbname=postgres user=alice', + 'everyone else authenticates with scram'); +$node->connect_ok('host=127.0.0.1 dbname=postgres user=bob', + 'admin matched by the more specific child'); +$node->connect_fails( + 'host=127.0.0.1 dbname=postgres user=jacob', + 'jacob is rejected by the more specific child', + expected_stderr => qr/rejects connection/); + +# The example of the documentation, with scram for LDAP: everyone over +# SSL, admins additionally need a client certificate, jacob is rejected. +# The ssl constraint is inherited, so every rule below it is hostssl, +# which clientcert needs. This cluster runs without SSL, so the rules +# load with the usual note that they cannot match. +set_hba(<<'EOF'); +{"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"ssl": "require", "auth": {"method": "scram-sha-256"}, + "children": [ + {"users": "+admins", "auth": {"clientcert": "verify-full"}}, + {"users": "jacob", "auth": {"method": "reject"}} + ]} +]} +EOF +$res = $node->safe_psql( + 'postgres', q{ + SELECT line_number, type, user_name, auth_method, options, + error ~ '^hostssl record cannot match' + FROM pg_hba_file_rules WHERE type <> 'local' ORDER BY line_number}); +is( $res, + join( + "\n", + '3|hostssl|{all}|scram-sha-256||t', + '5|hostssl|{+admins}|scram-sha-256|{clientcert=verify-full}|t', + '6|hostssl|{jacob}|reject||t'), + 'documented example loads as hostssl rules'); + +# auth merge: child without method adds an option, child with method +# replaces (ident, as one of the methods that take a map) +set_hba(<<'EOF'); +{"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"address": "127.0.0.1/32", "auth": {"method": "ident", "map": "m1"}, + "children": [ + {"users": "alice", "auth": {"method": "trust"}}, + {"users": "bob", "auth": {"map": "m2"}} + ]} +]} +EOF +$res = $node->safe_psql( + 'postgres', q{ + SELECT rule_number, auth_method, options FROM pg_hba_file_rules + WHERE rule_number > 1 ORDER BY rule_number}); +is($res, qq{2|trust|\n3|ident|{map=m2}\n4|ident|{map=m1}}, + 'auth replaced when child sets method, merged otherwise'); + +# address arrays: one flat rule per element, same node path +set_hba(<<'EOF'); +{"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"address": ["127.0.0.1/32", "::1/128"], "auth": {"method": "trust"}} +]} +EOF +$res = $node->safe_psql( + 'postgres', q{ + SELECT rule_number, address, node_path FROM pg_hba_file_rules + WHERE rule_number > 1 ORDER BY rule_number}); +is($res, qq{2|127.0.0.1|\$.hba.children[1]\n3|::1|\$.hba.children[1]}, + 'address array expands to one rule per element'); + +# positional import of a legacy file, inside a grouping node; a missing +# optional import is skipped +write_file("$datadir/legacy_hba.conf", + "host all alice 127.0.0.1/32 trust\n"); +set_hba(<<'EOF'); +{"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"children": [ + {"import": "missing.conf", "optional": true}, + {"import": "legacy_hba.conf"}, + {"address": "127.0.0.1/32", "auth": {"method": "reject"}} + ]} +]} +EOF +$node->reload; +$res = $node->safe_psql( + 'postgres', q{ + SELECT rule_number, regexp_replace(file_name, '.*/', ''), line_number, + user_name, auth_method, node_path IS NULL + FROM pg_hba_file_rules WHERE rule_number > 1 ORDER BY rule_number}); +is( $res, + join( + "\n", + '2|legacy_hba.conf|1|{alice}|trust|t', + '3|pg_hba.json|6|{all}|reject|f'), + 'import inserts legacy rules at its position'); +$node->connect_ok('host=127.0.0.1 dbname=postgres user=alice', + 'legacy rule matched before the sibling after it'); + +# an included document adds its rules after the including one; the +# include is relative to the document +mkdir "$datadir/hba.d"; +write_file("$datadir/hba.d/10-extra.json", <<'EOF'); +{"hba": {"children": [ + {"address": "127.0.0.1/32", "users": "bob", "auth": {"method": "trust"}} +]}} +EOF +write_file($hba_json, <<'EOF'); +{"include": ["hba.d/*.json", {"file": "missing.json", "optional": true}], + "hba": {"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"address": "127.0.0.1/32", "users": "alice", "auth": {"method": "trust"}} +]}} +EOF +$node->reload; +$res = $node->safe_psql( + 'postgres', q{ + SELECT rule_number, regexp_replace(file_name, '.*/', ''), line_number, + user_name, node_path + FROM pg_hba_file_rules WHERE rule_number > 1 ORDER BY rule_number}); +is( $res, + join("\n", + '2|pg_hba.json|4|{alice}|$.hba.children[1]', + '3|10-extra.json|2|{bob}|$.hba.children[0]'), + 'included document listed after the including one'); +$node->connect_ok('host=127.0.0.1 dbname=postgres user=bob', + 'rule from an included document matched'); + +# a pattern that matches nothing is an error unless optional: losing the +# last file of a directory of rejects must not let the rules after it +# through +mkdir "$datadir/deny.d"; +write_file("$datadir/deny.d/10-bob.json", <<'EOF'); +{"hba": {"children": [ + {"address": "127.0.0.1/32", "users": "bob", "auth": {"method": "reject"}} +]}} +EOF +write_file($hba_json, <<'EOF'); +{"include": ["deny.d/*.json", "hba.d/*.json"], + "hba": {"children": [ + {"transport": "local", "auth": {"method": "trust"}} +]}} +EOF +my $log_offset = -s $node->logfile; +$node->reload; +$node->wait_for_log(qr/reloading configuration files/, $log_offset); +$node->connect_fails('host=127.0.0.1 dbname=postgres user=bob', + 'rejected by the rule from the included directory'); + +$log_offset = -s $node->logfile; +unlink "$datadir/deny.d/10-bob.json"; +$node->reload; +$node->wait_for_log( + qr/include pattern "deny\.d\/\*\.json" matches no files/, $log_offset); +$node->wait_for_log(qr/pg_hba\.json was not reloaded/, $log_offset); +$node->connect_fails('host=127.0.0.1 dbname=postgres user=bob', + 'unmatched pattern fails the load, keeping the old rules'); + +write_file($hba_json, <<'EOF'); +{"include": [{"file": "deny.d/*.json", "optional": true}, "hba.d/*.json"], + "hba": {"children": [ + {"transport": "local", "auth": {"method": "trust"}} +]}} +EOF +$log_offset = -s $node->logfile; +$node->reload; +$node->wait_for_log(qr/reloading configuration files/, $log_offset); +$node->connect_ok('host=127.0.0.1 dbname=postgres user=bob', + 'unmatched optional pattern reads nothing'); +rmdir "$datadir/deny.d"; + +# an optional include may be missing, but not unreadable +SKIP: +{ + skip 'unix-style permissions not supported on Windows', 1 if $windows_os; + + my $log_offset = -s $node->logfile; + write_file($hba_json, <<'EOF'); +{"include": [{"file": "hba.d/10-extra.json", "optional": true}], + "hba": {"children": [ + {"transport": "local", "auth": {"method": "trust"}} +]}} +EOF + chmod 0000, "$datadir/hba.d/10-extra.json" or die $!; + $node->reload; + $node->wait_for_log( + qr/could not open configuration file ".*10-extra\.json": Permission denied/, + $log_offset); + $node->wait_for_log(qr/pg_hba\.json was not reloaded/, $log_offset); + chmod 0644, "$datadir/hba.d/10-extra.json" or die $!; + ok(1, 'unreadable optional include fails the load'); +} + +# @file in a core field is read relative to the JSON file +write_file("$datadir/roles.txt", "alice\nbob\n"); +set_hba(<<'EOF'); +{"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"address": "127.0.0.1/32", "users": "@roles.txt", + "auth": {"method": "trust"}}, + {"address": "127.0.0.1/32", "auth": {"method": "reject"}} +]} +EOF +$node->reload; +$res = $node->safe_psql('postgres', + q{SELECT user_name FROM pg_hba_file_rules WHERE rule_number = 2}); +is($res, '{alice,bob}', '@file expanded into the user list'); +$node->connect_ok('host=127.0.0.1 dbname=postgres user=bob', + 'user listed in the @file is matched'); +$node->connect_fails( + 'host=127.0.0.1 dbname=postgres user=jacob', + 'user not listed in the @file is rejected', + expected_stderr => qr/rejects connection/); + +# quoted literal keeps the role named "all" apart from the keyword +set_hba(<<'EOF'); +{"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"address": "127.0.0.1/32", "users": "\"all\"", + "auth": {"method": "trust"}}, + {"address": "127.0.0.1/32", "auth": {"method": "reject"}} +]} +EOF +$node->reload; +$node->connect_ok('host=127.0.0.1 dbname=postgres user=all', + 'quoted user token matches role named all'); +$node->connect_fails( + 'host=127.0.0.1 dbname=postgres user=alice', + 'quoted user token is not the keyword', + expected_stderr => qr/rejects connection/); + +# rejected shapes, one per rule, visible in the view; queried from a +# session opened beforehand, since a backend that loads the rules itself +# cannot start on them +write_file("$datadir/empty.txt", ""); +my $session = $node->background_psql('postgres', on_error_stop => 0); +set_hba(<<'EOF'); +{"children": [ + {"transport": "local", "auth": {"method": "trust"}}, + {"transport": "local", "address": "127.0.0.1/32", + "auth": {"method": "trust"}}, + {"ssl": "require", "gss": "require", "auth": {"method": "trust"}}, + {"bogus": 1, "auth": {"method": "trust"}}, + {"users": 42, "auth": {"method": "trust"}}, + {"import": "legacy_hba.conf", "children": []}, + {"users": "alice"}, + {"databases": "postgres", "children": []}, + {"auth": {"method": "scrim"}}, + {"users": "@missing.txt", "auth": {"method": "trust"}}, + {"users": "@empty.txt", "auth": {"method": "trust"}}, + {"transport": "serial", "children": [ + {"transport": "tcp", "auth": {"method": "trust"}} + ]}, + {"ssl": "forbid", "auth": {"method": "trust"}, "children": [ + {"transport": "local"} + ]}, + {"address": ["127.0.0.1/32", "10.0.0.1"], "auth": {"method": "trust"}} +]} +EOF +$res = $session->query_safe( + q{SELECT line_number, error FROM pg_hba_file_rules + WHERE error IS NOT NULL ORDER BY line_number}); +like( + $res, + qr/"address", "ssl" and "gss" cannot be used with transport "local"/, + 'local with address rejected'); +like($res, qr/"ssl" and "gss" cannot both be constrained/, + 'ssl+gss rejected'); +like($res, qr/unrecognized key "bogus"/, 'unrecognized key rejected'); +like( + $res, + qr/"users" must be a string or a non-empty array of strings/, + 'wrong type rejected'); +like($res, qr/import node cannot have "children"/, + 'children on import rejected'); +is(scalar(() = $res =~ /leaf node has no authentication method/g), + 2, 'leaf without method rejected, empty children array is a leaf'); +like($res, qr/invalid authentication method "scrim"/, + 'parse_hba_line errors pass through'); +like($res, qr/could not open file ".*missing\.txt"/, + 'missing @file rejected'); +like($res, qr/end-of-line before authentication method/, + 'empty @file drops the field, as in pg_hba.conf'); +like($res, qr/invalid value for "transport": "serial"/, + 'bad transport on grouping node rejected'); +like($res, qr/inherited "ssl" cannot be used with transport "local"/, + 'inherited constraint on local child rejected'); +like($res, qr/address "10\.0\.0\.1" must be in CIDR notation/, + 'address without prefix length rejected'); +is(scalar(() = $res =~ /\n/g) + 1, 13, 'one error per bad rule'); + +# a rejected reload keeps the rules loaded before +$node->reload; +$node->wait_for_log(qr/pg_hba\.json was not reloaded/); +SKIP: +{ + skip 'new backends load the rules themselves with EXEC_BACKEND', 2 + if $exec_backend eq 'on'; + + $node->connect_ok('host=127.0.0.1 dbname=postgres user=all', + 'old rules still in effect after a rejected reload'); + $node->connect_fails( + 'host=127.0.0.1 dbname=postgres user=alice', + 'old rules still reject what they rejected', + expected_stderr => qr/rejects connection/); +} + +# problems with the document itself fail the load as a whole, and the +# view reports them instead of returning rows +foreach my $case ( + [ '{"hba": {"children": []}, "ident": {"maps": {}}}', + qr/unrecognized key "ident"/ ], + [ '{"hba": {"children": []}, "work_mem": "1MB"}', + qr/unrecognized key "work_mem"/ ], + [ '{"hba": {"children": []}, "include": ["legacy_hba.conf"]}', + qr/included file ".*legacy_hba\.conf" is not a JSON file/ ], + [ '{"hba": {"children": []}, "include": ["missing.json"]}', + qr/could not open file ".*missing\.json"/ ], + [ '{"hba": []}', qr/"hba" must be an object with a "children" array/ ], + [ '{"hba": {"children": [}}', qr/Expected JSON value/ ]) +{ + my ($doc, $re) = @$case; + + write_file($hba_json, "$doc\n"); + $session->{stderr} = ''; + $session->query('SELECT * FROM pg_hba_file_rules'); + my $err = $session->{stderr}; + like($err, qr/could not load configuration file ".*pg_hba\.json"/, + "hba document $doc: view fails"); + like($err, $re, "hba document $doc: problem reported"); +} +$session->quit; + +# a document without rules: startup fails +$node->stop; +write_file($hba_json, "{}\n"); +my $ret = $node->start(fail_ok => 1); +is($ret, 0, 'hba document without rules fails to start'); +ok($node->log_contains(qr/contains no entries/), 'no entries reported'); + +done_testing(); diff --git a/src/test/regress/expected/rules.out b/src/test/regress/expected/rules.out index 0addd043e68..3d58f41a52c 100644 --- a/src/test/regress/expected/rules.out +++ b/src/test/regress/expected/rules.out @@ -1371,8 +1371,9 @@ pg_hba_file_rules| SELECT rule_number, netmask, auth_method, options, + node_path, error - FROM pg_hba_file_rules() a(rule_number, file_name, line_number, type, database, user_name, address, netmask, auth_method, options, error); + FROM pg_hba_file_rules() a(rule_number, file_name, line_number, type, database, user_name, address, netmask, auth_method, options, node_path, error); pg_ident_file_mappings| SELECT map_number, file_name, line_number, diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list index 3bed561f7f2..9ddf4ebdaa2 100644 --- a/src/tools/pgindent/typedefs.list +++ b/src/tools/pgindent/typedefs.list @@ -1237,6 +1237,7 @@ HashSkewBucket HashState HashValueFunc HbaLine +HbaNodeState HeadlineJsonState HeadlineParsedText HeadlineWordEntry @@ -1417,6 +1418,9 @@ JsonArrayQueryConstructor JsonBaseObjectInfo JsonBehavior JsonBehaviorType +JsonConfNode +JsonConfNodeType +JsonConfParseState JsonConstructorExpr JsonConstructorExprState JsonConstructorType -- 2.56.0