From eb15e1e649ada010cbda8b75ab6194c4ddeceace Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 28 May 2026 11:57:41 -0700 Subject: [PATCH 1/3] Allow project admin to manage extensions --- CLAUDE.md | 2 +- postgres/insforge_pg_utils/README.md | 5 + .../insforge_pg_utils/src/insforge_pg_utils.c | 104 +++++++++++++++++- 3 files changed, 108 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 237d19b..297c4c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ The custom Postgres image includes: - **PostGIS 3** - Spatial database support - **pg_cron** - Job scheduling within PostgreSQL - **pgsql-http** - HTTP client for making requests from SQL -- **insforge_pg_utils** - InsForge preload hook for scoped managed-table RLS policy administration +- **insforge_pg_utils** - InsForge preload hook for scoped managed-table RLS policy administration and project-admin extension management ## Build Process diff --git a/postgres/insforge_pg_utils/README.md b/postgres/insforge_pg_utils/README.md index a1f260f..8b91670 100644 --- a/postgres/insforge_pg_utils/README.md +++ b/postgres/insforge_pg_utils/README.md @@ -6,6 +6,11 @@ The first hook lets `insforge.policy_grant_role` manage row-level-security policies on the comma-separated `insforge.policy_grant_tables` allowlist without making that role the table owner. +The second hook lets `insforge.extension_grant_role` manage PostgreSQL +extensions without granting the role direct superuser or database-level `CREATE` +privileges. Extension utility statements are executed as the bootstrap +superuser, then the session role is restored. + This library must be installed into the Postgres image and listed in `shared_preload_libraries`. It does not currently need SQL-level `CREATE EXTENSION` installation because it exposes no SQL objects. diff --git a/postgres/insforge_pg_utils/src/insforge_pg_utils.c b/postgres/insforge_pg_utils/src/insforge_pg_utils.c index 7d4f76c..0751996 100644 --- a/postgres/insforge_pg_utils/src/insforge_pg_utils.c +++ b/postgres/insforge_pg_utils/src/insforge_pg_utils.c @@ -6,6 +6,7 @@ #include "access/relation.h" #include "catalog/namespace.h" #include "catalog/pg_class.h" +#include "catalog/pg_authid_d.h" #include "fmgr.h" #include "miscadmin.h" #include "nodes/parsenodes.h" @@ -23,6 +24,7 @@ static ProcessUtility_hook_type prev_ProcessUtility_hook = NULL; static char *policy_grant_role = NULL; static char *policy_grant_tables = NULL; +static char *extension_grant_role = NULL; void _PG_init(void); void _PG_fini(void); @@ -50,6 +52,9 @@ static RangeVar *get_drop_policy_target(DropStmt *stmt); static RangeVar *get_rename_policy_target(RenameStmt *stmt); static bool alter_table_only_updates_rls(AlterTableStmt *stmt); static bool current_role_matches_policy_grant_role(void); +static bool current_role_matches_extension_grant_role(void); +static bool current_role_matches_configured_role(const char *role_name); +static bool is_extension_utility_statement(Node *utility_stmt); static bool target_table_is_policy_granted(const RangeVar *target_table, Oid *target_table_oid); static void run_as_relation_owner(Oid relid, @@ -61,6 +66,14 @@ static void run_as_relation_owner(Oid relid, QueryEnvironment *queryEnv, DestReceiver *dest, QueryCompletion *qc); +static void run_as_bootstrap_superuser(PlannedStmt *pstmt, + const char *queryString, + bool readOnlyTree, + ProcessUtilityContext context, + ParamListInfo params, + QueryEnvironment *queryEnv, + DestReceiver *dest, + QueryCompletion *qc); static char *trim_token(char *token); void @@ -90,6 +103,18 @@ _PG_init(void) NULL, NULL); + DefineCustomStringVariable( + "insforge.extension_grant_role", + "Role allowed to manage installed PostgreSQL extensions without direct superuser privileges.", + NULL, + &extension_grant_role, + "project_admin", + PGC_SIGHUP, + 0, + NULL, + NULL, + NULL); + prev_ProcessUtility_hook = ProcessUtility_hook; ProcessUtility_hook = insforge_pg_utils_ProcessUtility; } @@ -126,6 +151,14 @@ insforge_pg_utils_ProcessUtility(PlannedStmt *pstmt, } } + if (!superuser() && current_role_matches_extension_grant_role() && + is_extension_utility_statement(utility_stmt)) + { + run_as_bootstrap_superuser(pstmt, queryString, readOnlyTree, context, + params, queryEnv, dest, qc); + return; + } + run_next_ProcessUtility(pstmt, queryString, readOnlyTree, context, params, queryEnv, dest, qc); } @@ -255,16 +288,51 @@ alter_table_only_updates_rls(AlterTableStmt *stmt) static bool current_role_matches_policy_grant_role(void) +{ + return current_role_matches_configured_role(policy_grant_role); +} + +static bool +current_role_matches_extension_grant_role(void) +{ + return current_role_matches_configured_role(extension_grant_role); +} + +static bool +current_role_matches_configured_role(const char *role_name) { const char *current_role_name; - if (policy_grant_role == NULL || policy_grant_role[0] == '\0') + if (role_name == NULL || role_name[0] == '\0') { return false; } current_role_name = GetUserNameFromId(GetUserId(), false); - return strcmp(current_role_name, policy_grant_role) == 0; + return strcmp(current_role_name, role_name) == 0; +} + +static bool +is_extension_utility_statement(Node *utility_stmt) +{ + if (utility_stmt == NULL) + { + return false; + } + + switch (nodeTag(utility_stmt)) + { + case T_CreateExtensionStmt: + case T_AlterExtensionStmt: + case T_AlterExtensionContentsStmt: + return true; + + case T_DropStmt: + return ((DropStmt *) utility_stmt)->removeType == OBJECT_EXTENSION; + + default: + return false; + } } static bool @@ -358,6 +426,38 @@ run_as_relation_owner(Oid relid, SetUserIdAndSecContext(save_userid, save_sec_context); } +static void +run_as_bootstrap_superuser(PlannedStmt *pstmt, + const char *queryString, + bool readOnlyTree, + ProcessUtilityContext context, + ParamListInfo params, + QueryEnvironment *queryEnv, + DestReceiver *dest, + QueryCompletion *qc) +{ + Oid save_userid; + int save_sec_context; + + GetUserIdAndSecContext(&save_userid, &save_sec_context); + SetUserIdAndSecContext(BOOTSTRAP_SUPERUSERID, + save_sec_context | SECURITY_LOCAL_USERID_CHANGE); + + PG_TRY(); + { + run_next_ProcessUtility(pstmt, queryString, readOnlyTree, context, params, + queryEnv, dest, qc); + } + PG_CATCH(); + { + SetUserIdAndSecContext(save_userid, save_sec_context); + PG_RE_THROW(); + } + PG_END_TRY(); + + SetUserIdAndSecContext(save_userid, save_sec_context); +} + static char * trim_token(char *token) { From 1ec5a55b27c20ebaafc65f1f90e7c19996015aba Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 28 May 2026 12:01:48 -0700 Subject: [PATCH 2/3] Refactor utility hook dispatch --- postgres/insforge_pg_utils/README.md | 16 +- .../insforge_pg_utils/src/insforge_pg_utils.c | 203 ++++++++---------- 2 files changed, 99 insertions(+), 120 deletions(-) diff --git a/postgres/insforge_pg_utils/README.md b/postgres/insforge_pg_utils/README.md index 8b91670..9da597e 100644 --- a/postgres/insforge_pg_utils/README.md +++ b/postgres/insforge_pg_utils/README.md @@ -1,14 +1,16 @@ # insforge_pg_utils -PostgreSQL shared-preload library for InsForge database permission hooks. +PostgreSQL shared-preload library for InsForge database utility permission +hooks. -The first hook lets `insforge.policy_grant_role` manage row-level-security -policies on the comma-separated `insforge.policy_grant_tables` allowlist without -making that role the table owner. +The utility hook currently delegates two statement classes. Policy statements +let `insforge.policy_grant_role` manage row-level-security policies on the +comma-separated `insforge.policy_grant_tables` allowlist without making that +role the table owner. -The second hook lets `insforge.extension_grant_role` manage PostgreSQL -extensions without granting the role direct superuser or database-level `CREATE` -privileges. Extension utility statements are executed as the bootstrap +Extension statements let `insforge.extension_grant_role` manage PostgreSQL +extensions without granting that role direct superuser or database-level +`CREATE` privileges. Extension utility statements are executed as the bootstrap superuser, then the session role is restored. This library must be installed into the Postgres image and listed in diff --git a/postgres/insforge_pg_utils/src/insforge_pg_utils.c b/postgres/insforge_pg_utils/src/insforge_pg_utils.c index 0751996..3c57432 100644 --- a/postgres/insforge_pg_utils/src/insforge_pg_utils.c +++ b/postgres/insforge_pg_utils/src/insforge_pg_utils.c @@ -26,6 +26,18 @@ static char *policy_grant_role = NULL; static char *policy_grant_tables = NULL; static char *extension_grant_role = NULL; +typedef struct InsforgeUtilityCall +{ + PlannedStmt *pstmt; + const char *queryString; + bool readOnlyTree; + ProcessUtilityContext context; + ParamListInfo params; + QueryEnvironment *queryEnv; + DestReceiver *dest; + QueryCompletion *qc; +} InsforgeUtilityCall; + void _PG_init(void); void _PG_fini(void); @@ -38,42 +50,23 @@ static void insforge_pg_utils_ProcessUtility(PlannedStmt *pstmt, DestReceiver *dest, QueryCompletion *qc); -static void run_next_ProcessUtility(PlannedStmt *pstmt, - const char *queryString, - bool readOnlyTree, - ProcessUtilityContext context, - ParamListInfo params, - QueryEnvironment *queryEnv, - DestReceiver *dest, - QueryCompletion *qc); +static void run_next_ProcessUtility(const InsforgeUtilityCall *call); -static RangeVar *get_policy_grant_target(Node *utility_stmt); +static bool handle_policy_utility(Node *utility_stmt, + const InsforgeUtilityCall *call); +static bool handle_extension_utility(Node *utility_stmt, + const InsforgeUtilityCall *call); +static RangeVar *get_policy_utility_target(Node *utility_stmt); static RangeVar *get_drop_policy_target(DropStmt *stmt); static RangeVar *get_rename_policy_target(RenameStmt *stmt); static bool alter_table_only_updates_rls(AlterTableStmt *stmt); -static bool current_role_matches_policy_grant_role(void); -static bool current_role_matches_extension_grant_role(void); static bool current_role_matches_configured_role(const char *role_name); static bool is_extension_utility_statement(Node *utility_stmt); -static bool target_table_is_policy_granted(const RangeVar *target_table, - Oid *target_table_oid); +static bool policy_target_is_configured(const RangeVar *target_table, + Oid *target_table_oid); static void run_as_relation_owner(Oid relid, - PlannedStmt *pstmt, - const char *queryString, - bool readOnlyTree, - ProcessUtilityContext context, - ParamListInfo params, - QueryEnvironment *queryEnv, - DestReceiver *dest, - QueryCompletion *qc); -static void run_as_bootstrap_superuser(PlannedStmt *pstmt, - const char *queryString, - bool readOnlyTree, - ProcessUtilityContext context, - ParamListInfo params, - QueryEnvironment *queryEnv, - DestReceiver *dest, - QueryCompletion *qc); + const InsforgeUtilityCall *call); +static void run_as_user(Oid userid, const InsforgeUtilityCall *call); static char *trim_token(char *token); void @@ -136,57 +129,87 @@ insforge_pg_utils_ProcessUtility(PlannedStmt *pstmt, QueryCompletion *qc) { Node *utility_stmt = pstmt->utilityStmt; - RangeVar *target_table = NULL; - Oid target_table_oid = InvalidOid; - - if (!superuser() && current_role_matches_policy_grant_role()) + InsforgeUtilityCall call; + + call.pstmt = pstmt; + call.queryString = queryString; + call.readOnlyTree = readOnlyTree; + call.context = context; + call.params = params; + call.queryEnv = queryEnv; + call.dest = dest; + call.qc = qc; + + if (!superuser()) { - target_table = get_policy_grant_target(utility_stmt); - if (target_table != NULL && - target_table_is_policy_granted(target_table, &target_table_oid)) + if (handle_policy_utility(utility_stmt, &call)) { - run_as_relation_owner(target_table_oid, pstmt, queryString, readOnlyTree, - context, params, queryEnv, dest, qc); return; } - } - if (!superuser() && current_role_matches_extension_grant_role() && - is_extension_utility_statement(utility_stmt)) - { - run_as_bootstrap_superuser(pstmt, queryString, readOnlyTree, context, - params, queryEnv, dest, qc); - return; + if (handle_extension_utility(utility_stmt, &call)) + { + return; + } } - run_next_ProcessUtility(pstmt, queryString, readOnlyTree, context, params, - queryEnv, dest, qc); + run_next_ProcessUtility(&call); } static void -run_next_ProcessUtility(PlannedStmt *pstmt, - const char *queryString, - bool readOnlyTree, - ProcessUtilityContext context, - ParamListInfo params, - QueryEnvironment *queryEnv, - DestReceiver *dest, - QueryCompletion *qc) +run_next_ProcessUtility(const InsforgeUtilityCall *call) { if (prev_ProcessUtility_hook) { - prev_ProcessUtility_hook(pstmt, queryString, readOnlyTree, context, params, - queryEnv, dest, qc); + prev_ProcessUtility_hook(call->pstmt, call->queryString, + call->readOnlyTree, call->context, call->params, + call->queryEnv, call->dest, call->qc); } else { - standard_ProcessUtility(pstmt, queryString, readOnlyTree, context, params, - queryEnv, dest, qc); + standard_ProcessUtility(call->pstmt, call->queryString, + call->readOnlyTree, call->context, call->params, + call->queryEnv, call->dest, call->qc); + } +} + +static bool +handle_policy_utility(Node *utility_stmt, const InsforgeUtilityCall *call) +{ + RangeVar *target_table = NULL; + Oid target_table_oid = InvalidOid; + + if (!current_role_matches_configured_role(policy_grant_role)) + { + return false; } + + target_table = get_policy_utility_target(utility_stmt); + if (target_table == NULL || + !policy_target_is_configured(target_table, &target_table_oid)) + { + return false; + } + + run_as_relation_owner(target_table_oid, call); + return true; +} + +static bool +handle_extension_utility(Node *utility_stmt, const InsforgeUtilityCall *call) +{ + if (!current_role_matches_configured_role(extension_grant_role) || + !is_extension_utility_statement(utility_stmt)) + { + return false; + } + + run_as_user(BOOTSTRAP_SUPERUSERID, call); + return true; } static RangeVar * -get_policy_grant_target(Node *utility_stmt) +get_policy_utility_target(Node *utility_stmt) { if (utility_stmt == NULL) { @@ -286,18 +309,6 @@ alter_table_only_updates_rls(AlterTableStmt *stmt) return true; } -static bool -current_role_matches_policy_grant_role(void) -{ - return current_role_matches_configured_role(policy_grant_role); -} - -static bool -current_role_matches_extension_grant_role(void) -{ - return current_role_matches_configured_role(extension_grant_role); -} - static bool current_role_matches_configured_role(const char *role_name) { @@ -336,8 +347,8 @@ is_extension_utility_statement(Node *utility_stmt) } static bool -target_table_is_policy_granted(const RangeVar *target_table, - Oid *target_table_oid) +policy_target_is_configured(const RangeVar *target_table, + Oid *target_table_oid) { char *tables_copy; char *table_token; @@ -388,18 +399,8 @@ target_table_is_policy_granted(const RangeVar *target_table, } static void -run_as_relation_owner(Oid relid, - PlannedStmt *pstmt, - const char *queryString, - bool readOnlyTree, - ProcessUtilityContext context, - ParamListInfo params, - QueryEnvironment *queryEnv, - DestReceiver *dest, - QueryCompletion *qc) +run_as_relation_owner(Oid relid, const InsforgeUtilityCall *call) { - Oid save_userid; - int save_sec_context; Relation relation; Oid owner_id; @@ -407,46 +408,22 @@ run_as_relation_owner(Oid relid, owner_id = RelationGetForm(relation)->relowner; relation_close(relation, NoLock); - GetUserIdAndSecContext(&save_userid, &save_sec_context); - SetUserIdAndSecContext(owner_id, - save_sec_context | SECURITY_LOCAL_USERID_CHANGE); - - PG_TRY(); - { - run_next_ProcessUtility(pstmt, queryString, readOnlyTree, context, params, - queryEnv, dest, qc); - } - PG_CATCH(); - { - SetUserIdAndSecContext(save_userid, save_sec_context); - PG_RE_THROW(); - } - PG_END_TRY(); - - SetUserIdAndSecContext(save_userid, save_sec_context); + run_as_user(owner_id, call); } static void -run_as_bootstrap_superuser(PlannedStmt *pstmt, - const char *queryString, - bool readOnlyTree, - ProcessUtilityContext context, - ParamListInfo params, - QueryEnvironment *queryEnv, - DestReceiver *dest, - QueryCompletion *qc) +run_as_user(Oid userid, const InsforgeUtilityCall *call) { Oid save_userid; int save_sec_context; GetUserIdAndSecContext(&save_userid, &save_sec_context); - SetUserIdAndSecContext(BOOTSTRAP_SUPERUSERID, + SetUserIdAndSecContext(userid, save_sec_context | SECURITY_LOCAL_USERID_CHANGE); PG_TRY(); { - run_next_ProcessUtility(pstmt, queryString, readOnlyTree, context, params, - queryEnv, dest, qc); + run_next_ProcessUtility(call); } PG_CATCH(); { From c3b90d21d360cb96913205aa659371157b935bc3 Mon Sep 17 00:00:00 2001 From: Lyu Date: Thu, 28 May 2026 12:08:14 -0700 Subject: [PATCH 3/3] Polish utility hook call context --- .../insforge_pg_utils/src/insforge_pg_utils.c | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/postgres/insforge_pg_utils/src/insforge_pg_utils.c b/postgres/insforge_pg_utils/src/insforge_pg_utils.c index 3c57432..9fbfa6a 100644 --- a/postgres/insforge_pg_utils/src/insforge_pg_utils.c +++ b/postgres/insforge_pg_utils/src/insforge_pg_utils.c @@ -29,6 +29,7 @@ static char *extension_grant_role = NULL; typedef struct InsforgeUtilityCall { PlannedStmt *pstmt; + Node *utility_stmt; const char *queryString; bool readOnlyTree; ProcessUtilityContext context; @@ -52,10 +53,8 @@ static void insforge_pg_utils_ProcessUtility(PlannedStmt *pstmt, static void run_next_ProcessUtility(const InsforgeUtilityCall *call); -static bool handle_policy_utility(Node *utility_stmt, - const InsforgeUtilityCall *call); -static bool handle_extension_utility(Node *utility_stmt, - const InsforgeUtilityCall *call); +static bool handle_policy_utility(const InsforgeUtilityCall *call); +static bool handle_extension_utility(const InsforgeUtilityCall *call); static RangeVar *get_policy_utility_target(Node *utility_stmt); static RangeVar *get_drop_policy_target(DropStmt *stmt); static RangeVar *get_rename_policy_target(RenameStmt *stmt); @@ -128,10 +127,10 @@ insforge_pg_utils_ProcessUtility(PlannedStmt *pstmt, DestReceiver *dest, QueryCompletion *qc) { - Node *utility_stmt = pstmt->utilityStmt; InsforgeUtilityCall call; call.pstmt = pstmt; + call.utility_stmt = pstmt->utilityStmt; call.queryString = queryString; call.readOnlyTree = readOnlyTree; call.context = context; @@ -142,12 +141,12 @@ insforge_pg_utils_ProcessUtility(PlannedStmt *pstmt, if (!superuser()) { - if (handle_policy_utility(utility_stmt, &call)) + if (handle_policy_utility(&call)) { return; } - if (handle_extension_utility(utility_stmt, &call)) + if (handle_extension_utility(&call)) { return; } @@ -174,7 +173,7 @@ run_next_ProcessUtility(const InsforgeUtilityCall *call) } static bool -handle_policy_utility(Node *utility_stmt, const InsforgeUtilityCall *call) +handle_policy_utility(const InsforgeUtilityCall *call) { RangeVar *target_table = NULL; Oid target_table_oid = InvalidOid; @@ -184,7 +183,7 @@ handle_policy_utility(Node *utility_stmt, const InsforgeUtilityCall *call) return false; } - target_table = get_policy_utility_target(utility_stmt); + target_table = get_policy_utility_target(call->utility_stmt); if (target_table == NULL || !policy_target_is_configured(target_table, &target_table_oid)) { @@ -196,10 +195,10 @@ handle_policy_utility(Node *utility_stmt, const InsforgeUtilityCall *call) } static bool -handle_extension_utility(Node *utility_stmt, const InsforgeUtilityCall *call) +handle_extension_utility(const InsforgeUtilityCall *call) { if (!current_role_matches_configured_role(extension_grant_role) || - !is_extension_utility_statement(utility_stmt)) + !is_extension_utility_statement(call->utility_stmt)) { return false; }