diff --git a/.github/workflows/build-postgres.yml b/.github/workflows/build-postgres.yml index 3da51b8..f2f7f7d 100644 --- a/.github/workflows/build-postgres.yml +++ b/.github/workflows/build-postgres.yml @@ -37,6 +37,7 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} @@ -58,7 +59,7 @@ jobs: context: ./postgres file: ./postgres/Dockerfile platforms: linux/amd64,linux/arm64 - push: true + push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha diff --git a/CLAUDE.md b/CLAUDE.md index 856bb55..237d19b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +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 ## Build Process diff --git a/README.md b/README.md index 8fb2969..ec56acf 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This image is based on **PostgreSQL 15.13** and includes the following extension - **[PostGIS 3](https://postgis.net/)** - Spatial and geographic objects support - **[pg_cron](https://github.com/citusdata/pg_cron)** - Job scheduling within PostgreSQL - **[pgsql-http](https://github.com/pramsey/pgsql-http)** - HTTP client for making requests from SQL +- **insforge_pg_utils** - InsForge preload hook for scoped managed-table RLS policy administration (loaded via `shared_preload_libraries`, not `CREATE EXTENSION`) ## Quick Start diff --git a/postgres/Dockerfile b/postgres/Dockerfile index 706760c..1161c3d 100644 --- a/postgres/Dockerfile +++ b/postgres/Dockerfile @@ -38,6 +38,12 @@ RUN cd /tmp && \ cd / && \ rm -rf /tmp/pg_graphql +# Build InsForge PostgreSQL utility hooks +COPY insforge_pg_utils /tmp/insforge_pg_utils +RUN make -C /tmp/insforge_pg_utils PG_CONFIG=/usr/lib/postgresql/15/bin/pg_config && \ + make -C /tmp/insforge_pg_utils install PG_CONFIG=/usr/lib/postgresql/15/bin/pg_config && \ + rm -rf /tmp/insforge_pg_utils + # Build pg_net (async HTTP requests) - C extension using libcurl # Note: v0.14.0 has curl type warnings, disable -Werror for compatibility with newer compilers RUN apt-get update && apt-get install -y libcurl4-openssl-dev && \ @@ -85,6 +91,7 @@ COPY --from=builder /usr/share/postgresql/15/extension/pg_graphql* /usr/share/po # pg_net may create versioned .so files, copy all matching patterns COPY --from=builder /usr/lib/postgresql/15/lib/pg_net*.so /usr/lib/postgresql/15/lib/ COPY --from=builder /usr/share/postgresql/15/extension/pg_net* /usr/share/postgresql/15/extension/ +COPY --from=builder /usr/lib/postgresql/15/lib/insforge_pg_utils.so /usr/lib/postgresql/15/lib/ # Clean up unnecessary packages RUN apt-get purge -y lsb-release curl gnupg && \ diff --git a/postgres/insforge_pg_utils/Makefile b/postgres/insforge_pg_utils/Makefile new file mode 100644 index 0000000..a80a347 --- /dev/null +++ b/postgres/insforge_pg_utils/Makefile @@ -0,0 +1,6 @@ +MODULE_big = insforge_pg_utils +OBJS = src/insforge_pg_utils.o + +PG_CONFIG ?= pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) diff --git a/postgres/insforge_pg_utils/README.md b/postgres/insforge_pg_utils/README.md new file mode 100644 index 0000000..a1f260f --- /dev/null +++ b/postgres/insforge_pg_utils/README.md @@ -0,0 +1,11 @@ +# insforge_pg_utils + +PostgreSQL shared-preload library for InsForge database 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. + +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 new file mode 100644 index 0000000..7d4f76c --- /dev/null +++ b/postgres/insforge_pg_utils/src/insforge_pg_utils.c @@ -0,0 +1,379 @@ +#include "postgres.h" + +#include + +#include "access/htup_details.h" +#include "access/relation.h" +#include "catalog/namespace.h" +#include "catalog/pg_class.h" +#include "fmgr.h" +#include "miscadmin.h" +#include "nodes/parsenodes.h" +#include "parser/parser.h" +#include "storage/lockdefs.h" +#include "tcop/utility.h" +#include "utils/guc.h" +#include "utils/lsyscache.h" +#include "utils/regproc.h" +#include "utils/rel.h" + +PG_MODULE_MAGIC; + +static ProcessUtility_hook_type prev_ProcessUtility_hook = NULL; + +static char *policy_grant_role = NULL; +static char *policy_grant_tables = NULL; + +void _PG_init(void); +void _PG_fini(void); + +static void insforge_pg_utils_ProcessUtility(PlannedStmt *pstmt, + const char *queryString, + bool readOnlyTree, + ProcessUtilityContext context, + ParamListInfo params, + QueryEnvironment *queryEnv, + 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 RangeVar *get_policy_grant_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 target_table_is_policy_granted(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 char *trim_token(char *token); + +void +_PG_init(void) +{ + DefineCustomStringVariable( + "insforge.policy_grant_role", + "Role allowed to manage policies on configured managed tables.", + NULL, + &policy_grant_role, + "project_admin", + PGC_SIGHUP, + 0, + NULL, + NULL, + NULL); + + DefineCustomStringVariable( + "insforge.policy_grant_tables", + "Comma-separated managed tables whose policies can be managed by insforge.policy_grant_role.", + NULL, + &policy_grant_tables, + "", + PGC_SIGHUP, + 0, + NULL, + NULL, + NULL); + + prev_ProcessUtility_hook = ProcessUtility_hook; + ProcessUtility_hook = insforge_pg_utils_ProcessUtility; +} + +void +_PG_fini(void) +{ + ProcessUtility_hook = prev_ProcessUtility_hook; +} + +static void +insforge_pg_utils_ProcessUtility(PlannedStmt *pstmt, + const char *queryString, + bool readOnlyTree, + ProcessUtilityContext context, + ParamListInfo params, + QueryEnvironment *queryEnv, + DestReceiver *dest, + QueryCompletion *qc) +{ + Node *utility_stmt = pstmt->utilityStmt; + RangeVar *target_table = NULL; + Oid target_table_oid = InvalidOid; + + if (!superuser() && current_role_matches_policy_grant_role()) + { + target_table = get_policy_grant_target(utility_stmt); + if (target_table != NULL && + target_table_is_policy_granted(target_table, &target_table_oid)) + { + run_as_relation_owner(target_table_oid, pstmt, queryString, readOnlyTree, + context, params, queryEnv, dest, qc); + return; + } + } + + run_next_ProcessUtility(pstmt, queryString, readOnlyTree, context, params, + queryEnv, dest, qc); +} + +static void +run_next_ProcessUtility(PlannedStmt *pstmt, + const char *queryString, + bool readOnlyTree, + ProcessUtilityContext context, + ParamListInfo params, + QueryEnvironment *queryEnv, + DestReceiver *dest, + QueryCompletion *qc) +{ + if (prev_ProcessUtility_hook) + { + prev_ProcessUtility_hook(pstmt, queryString, readOnlyTree, context, params, + queryEnv, dest, qc); + } + else + { + standard_ProcessUtility(pstmt, queryString, readOnlyTree, context, params, + queryEnv, dest, qc); + } +} + +static RangeVar * +get_policy_grant_target(Node *utility_stmt) +{ + if (utility_stmt == NULL) + { + return NULL; + } + + switch (nodeTag(utility_stmt)) + { + case T_CreatePolicyStmt: + return ((CreatePolicyStmt *) utility_stmt)->table; + + case T_AlterPolicyStmt: + return ((AlterPolicyStmt *) utility_stmt)->table; + + case T_DropStmt: + return get_drop_policy_target((DropStmt *) utility_stmt); + + case T_RenameStmt: + return get_rename_policy_target((RenameStmt *) utility_stmt); + + case T_AlterTableStmt: + if (alter_table_only_updates_rls((AlterTableStmt *) utility_stmt)) + { + return ((AlterTableStmt *) utility_stmt)->relation; + } + return NULL; + + default: + return NULL; + } +} + +static RangeVar * +get_rename_policy_target(RenameStmt *stmt) +{ + if (stmt->renameType != OBJECT_POLICY) + { + return NULL; + } + + return stmt->relation; +} + +static RangeVar * +get_drop_policy_target(DropStmt *stmt) +{ + List *object_names; + List *table_names = NIL; + ListCell *lc; + int index = 0; + int table_name_count; + + if (stmt->removeType != OBJECT_POLICY || list_length(stmt->objects) != 1) + { + return NULL; + } + + object_names = (List *) linitial(stmt->objects); + if (object_names == NIL || list_length(object_names) < 2) + { + return NULL; + } + + table_name_count = list_length(object_names) - 1; + foreach(lc, object_names) + { + if (index < table_name_count) + { + table_names = lappend(table_names, lfirst(lc)); + } + index++; + } + + return makeRangeVarFromNameList(table_names); +} + +static bool +alter_table_only_updates_rls(AlterTableStmt *stmt) +{ + ListCell *lc; + + if (stmt->relation == NULL || stmt->cmds == NIL) + { + return false; + } + + foreach(lc, stmt->cmds) + { + AlterTableCmd *cmd = (AlterTableCmd *) lfirst(lc); + if (cmd->subtype != AT_EnableRowSecurity && + cmd->subtype != AT_DisableRowSecurity) + { + return false; + } + } + + return true; +} + +static bool +current_role_matches_policy_grant_role(void) +{ + const char *current_role_name; + + if (policy_grant_role == NULL || policy_grant_role[0] == '\0') + { + return false; + } + + current_role_name = GetUserNameFromId(GetUserId(), false); + return strcmp(current_role_name, policy_grant_role) == 0; +} + +static bool +target_table_is_policy_granted(const RangeVar *target_table, + Oid *target_table_oid) +{ + char *tables_copy; + char *table_token; + char *cursor; + + if (policy_grant_tables == NULL || policy_grant_tables[0] == '\0') + { + return false; + } + + *target_table_oid = RangeVarGetRelid((RangeVar *) target_table, + AccessExclusiveLock, + false); + + tables_copy = pstrdup(policy_grant_tables); + cursor = tables_copy; + + while ((table_token = strsep(&cursor, ",")) != NULL) + { + char *trimmed_table = trim_token(table_token); + List *qualified_name; + RangeVar *allowlisted_table; + Oid allowlisted_table_oid; + + if (trimmed_table[0] == '\0') + { + continue; + } + + qualified_name = stringToQualifiedNameList(trimmed_table); + if (qualified_name == NIL) + { + continue; + } + + allowlisted_table = makeRangeVarFromNameList(qualified_name); + allowlisted_table_oid = RangeVarGetRelid(allowlisted_table, NoLock, true); + if (OidIsValid(allowlisted_table_oid) && + allowlisted_table_oid == *target_table_oid) + { + pfree(tables_copy); + return true; + } + } + + pfree(tables_copy); + return false; +} + +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) +{ + Oid save_userid; + int save_sec_context; + Relation relation; + Oid owner_id; + + relation = relation_open(relid, NoLock); + 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); +} + +static char * +trim_token(char *token) +{ + char *end; + + while (*token != '\0' && isspace((unsigned char) *token)) + { + token++; + } + + end = token + strlen(token); + while (end > token && isspace((unsigned char) *(end - 1))) + { + end--; + } + *end = '\0'; + + return token; +}