Iam postgres

From UVOO Tech Wiki
Revision as of 19:38, 19 September 2025 by Busk (talk | contribs) (Created page with "``` Two options I have a postgresql application that uses postgrest for http api and keycloak to issue JWTs for access on the http api. Please create an easy to manage ge...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search
Two options


I have a postgresql application that uses postgrest for http api and keycloak to issue JWTs for access on the http api.



Please create an easy to manage generic IAM system for the Postgresql database that can easily add new schemas.tables access to JWT issued users where the users have CRUD access to the objects they create. If possible without getting too complex add a group system where other JWTs users could easily have CRUD access to resource by adding them to a group. I'd like this to be as simple as possible and performant so it works with large tables and can easily add new tables. Use example tables of todos and chats to test this out. I'm sure you have existing methods that people have done this before so I'm trusting you to come up with the best solution.





Here are related roles that postgrest uses

-- Owner for schemas/objects (no login)

create role api_owner noinherit;



-- Runtime roles (what requests run as)

create role web_anon nologin;

create role web_user nologin;



-- PostgREST connection user

create role authenticator login noinherit password 'mypass';



-- Allow PostgREST to SET ROLE to runtime roles

grant web_anon to authenticator;

grant web_user to authenticator;


Which response is more helpful?
Your choice will help Gemini give you more helpful responses. Loading the response may take a moment.
Choice A
Of course. Here is a comprehensive, simple, and performant IAM system for PostgreSQL that integrates seamlessly with PostgREST and Keycloak JWTs.

This solution is built on standard PostgreSQL features like Row-Level Security (RLS) and is designed to be generic. You can easily apply this pattern to new schemas and tables.

The Core Concept
User Identity: We'll use the JWT sub (subject) claim, a unique UUID from Keycloak, to identify users.

Auto-Provisioning: A helper function will automatically create a user record in our database on their first API call, making user management zero-touch.

Ownership: Every protected resource (like a todo or a chat message) will have an owner_id column.

Group Sharing: Resources can be shared with groups via a simple _shares table for each resource type. This allows multiple groups to access a single resource with fine-grained permissions (read, write, delete).

Enforcement: PostgreSQL's Row-Level Security (RLS) policies enforce all access rules directly in the database, ensuring they are secure, performant, and cannot be bypassed.

1. IAM Schema and Helper Functions
First, we create a dedicated schema iam to hold our core tables and functions. This keeps the security logic separate from your application data.

The most important piece is the iam.current_user_id() function, which securely reads the user's ID from the JWT and creates a local user record if one doesn't exist.

SQL

-- Create a schema to hold our IAM logic
CREATE SCHEMA iam;
ALTER SCHEMA iam OWNER TO api_owner;

--------------------------------------------------------------------------------
-- TABLE: iam.users
-- Stores a local reference to our application users based on their JWT sub.
--------------------------------------------------------------------------------
CREATE TABLE iam.users (
    id         UUID PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE iam.users OWNER TO api_owner;
COMMENT ON TABLE iam.users IS 'Stores application users, keyed by their Keycloak JWT sub.';


--------------------------------------------------------------------------------
-- HELPER FUNCTION: iam.current_user_sub()
-- Extracts the 'sub' claim from the current JWT.
--------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION iam.current_user_sub()
RETURNS UUID AS $$
  SELECT nullif(current_setting('request.jwt.claims', true)::jsonb ->> 'sub', '')::uuid;
$$ LANGUAGE sql STABLE;
ALTER FUNCTION iam.current_user_sub() OWNER TO api_owner;
COMMENT ON FUNCTION iam.current_user_sub() IS 'Get the UUID of the currently authenticated user from the JWT sub claim.';


--------------------------------------------------------------------------------
-- HELPER FUNCTION: iam.current_user_id()
-- Gets the current user''s UUID and auto-provisions a local record if needed.
-- This is the main function you will use in policies and default values.
--------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION iam.current_user_id()
RETURNS UUID AS $$
DECLARE
  user_id UUID := iam.current_user_sub();
BEGIN
  IF user_id IS NOT NULL THEN
    -- On first-time access, create a local user record automatically.
    INSERT INTO iam.users (id) VALUES (user_id) ON CONFLICT (id) DO NOTHING;
  END IF;
  RETURN user_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
ALTER FUNCTION iam.current_user_id() OWNER TO api_owner;
COMMENT ON FUNCTION iam.current_user_id() IS 'Get current user ID, creating a local record on first access.';


--------------------------------------------------------------------------------
-- TABLES: iam.groups & iam.group_members
-- For managing group-based permissions.
--------------------------------------------------------------------------------
CREATE TABLE iam.groups (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    owner_id   UUID NOT NULL REFERENCES iam.users(id) DEFAULT iam.current_user_id(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE iam.groups OWNER TO api_owner;
CREATE INDEX ON iam.groups(owner_id);

CREATE TABLE iam.group_members (
    group_id   BIGINT NOT NULL REFERENCES iam.groups(id) ON DELETE CASCADE,
    user_id    UUID NOT NULL REFERENCES iam.users(id) ON DELETE CASCADE,
    PRIMARY KEY (group_id, user_id)
);
ALTER TABLE iam.group_members OWNER TO api_owner;

-- Allow regular users to manage their own groups and memberships
GRANT SELECT, INSERT, UPDATE, DELETE ON iam.groups TO web_user;
GRANT SELECT, INSERT, DELETE ON iam.group_members TO web_user;

-- RLS for groups: Users can only see/manage groups they own or are members of.
ALTER TABLE iam.groups ENABLE ROW LEVEL SECURITY;
CREATE POLICY groups_rls_policy ON iam.groups FOR ALL TO web_user
  USING (
    owner_id = iam.current_user_id() OR
    EXISTS (
      SELECT 1 FROM iam.group_members gm WHERE gm.group_id = id AND gm.user_id = iam.current_user_id()
    )
  );

-- RLS for group members: Users can only see/manage memberships for groups they own.
ALTER TABLE iam.group_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY group_members_rls_policy ON iam.group_members FOR ALL TO web_user
  USING (
    EXISTS (
      SELECT 1 FROM iam.groups g WHERE g.id = group_id AND g.owner_id = iam.current_user_id()
    )
  );
2. Example Application Schema and Tables
Now, let's create a data schema with our todos and chats tables. Notice how they follow a simple pattern:

An owner_id column that defaults to the current user.

A corresponding _shares table for group permissions.

SQL

-- A schema for our application data
CREATE SCHEMA data;
ALTER SCHEMA data OWNER TO api_owner;

--------------------------------------------------------------------------------
-- EXAMPLE 1: TODOS
--------------------------------------------------------------------------------
CREATE TABLE data.todos (
    id         BIGSERIAL PRIMARY KEY,
    owner_id   UUID NOT NULL REFERENCES iam.users(id) DEFAULT iam.current_user_id(),
    title      TEXT NOT NULL,
    done       BOOLEAN NOT NULL DEFAULT false,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE data.todos OWNER TO api_owner;

CREATE TABLE data.todo_shares (
    todo_id    BIGINT NOT NULL REFERENCES data.todos(id) ON DELETE CASCADE,
    group_id   BIGINT NOT NULL REFERENCES iam.groups(id) ON DELETE CASCADE,
    can_read   BOOLEAN NOT NULL DEFAULT true,
    can_write  BOOLEAN NOT NULL DEFAULT false,
    can_delete BOOLEAN NOT NULL DEFAULT false,
    PRIMARY KEY (todo_id, group_id)
);
ALTER TABLE data.todo_shares OWNER TO api_owner;
CREATE INDEX ON data.todo_shares(group_id);

--------------------------------------------------------------------------------
-- EXAMPLE 2: CHATS
--------------------------------------------------------------------------------
CREATE TABLE data.chats (
    id         BIGSERIAL PRIMARY KEY,
    owner_id   UUID NOT NULL REFERENCES iam.users(id) DEFAULT iam.current_user_id(),
    message    TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE data.chats OWNER TO api_owner;

CREATE TABLE data.chat_shares (
    chat_id    BIGINT NOT NULL REFERENCES data.chats(id) ON DELETE CASCADE,
    group_id   BIGINT NOT NULL REFERENCES iam.groups(id) ON DELETE CASCADE,
    can_read   BOOLEAN NOT NULL DEFAULT true,
    can_write  BOOLEAN NOT NULL DEFAULT false,
    can_delete BOOLEAN NOT NULL DEFAULT false,
    PRIMARY KEY (chat_id, group_id)
);
ALTER TABLE data.chat_shares OWNER TO api_owner;
CREATE INDEX ON data.chat_shares(group_id);
3. Permissions and RLS Policies
This is where we enforce the rules. We grant basic CRUD permissions to the web_user role and then apply RLS policies to filter which rows they can actually affect.

The policy logic is the same for every table: A user can access a row if they are the owner, OR if the row has been shared with a group they are a member of.

SQL

-- Grant basic permissions to the web_user role
GRANT USAGE ON SCHEMA iam, data TO web_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA data TO web_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA data TO web_user;


--------------------------------------------------------------------------------
-- RLS POLICY: TODOS
--------------------------------------------------------------------------------
ALTER TABLE data.todos ENABLE ROW LEVEL SECURITY;

CREATE POLICY todos_rls_policy ON data.todos FOR ALL TO web_user
  USING (
    -- The user is the owner
    owner_id = iam.current_user_id() OR
    -- The todo is shared with a group the user is a member of
    EXISTS (
      SELECT 1
      FROM data.todo_shares s
      JOIN iam.group_members gm ON s.group_id = gm.group_id
      WHERE s.todo_id = id
        AND gm.user_id = iam.current_user_id()
        AND (
          -- Logic for different commands
          CASE
            WHEN (current_setting('request.method', true) = 'DELETE') THEN s.can_delete
            WHEN (current_setting('request.method', true) IN ('POST', 'PATCH')) THEN s.can_write
            ELSE s.can_read -- Default to read for GET, HEAD, etc.
          END
        )
    )
  )
  WITH CHECK (
    -- When inserting or updating, check for ownership or write access
    owner_id = iam.current_user_id() OR
    EXISTS (
      SELECT 1
      FROM data.todo_shares s
      JOIN iam.group_members gm ON s.group_id = gm.group_id
      WHERE s.todo_id = id AND gm.user_id = iam.current_user_id() AND s.can_write
    )
  );

-- RLS for shares: only the owner of the parent resource can manage its shares
ALTER TABLE data.todo_shares ENABLE ROW LEVEL SECURITY;
CREATE POLICY todo_shares_rls_policy ON data.todo_shares FOR ALL TO web_user
  USING (
    EXISTS (
      SELECT 1 FROM data.todos t WHERE t.id = todo_id AND t.owner_id = iam.current_user_id()
    )
  );


--------------------------------------------------------------------------------
-- RLS POLICY: CHATS (Identical logic, just different table names)
--------------------------------------------------------------------------------
ALTER TABLE data.chats ENABLE ROW LEVEL SECURITY;

CREATE POLICY chats_rls_policy ON data.chats FOR ALL TO web_user
  USING (
    owner_id = iam.current_user_id() OR
    EXISTS (
      SELECT 1
      FROM data.chat_shares s
      JOIN iam.group_members gm ON s.group_id = gm.group_id
      WHERE s.chat_id = id
        AND gm.user_id = iam.current_user_id()
        AND (
          CASE
            WHEN (current_setting('request.method', true) = 'DELETE') THEN s.can_delete
            WHEN (current_setting('request.method', true) IN ('POST', 'PATCH')) THEN s.can_write
            ELSE s.can_read
          END
        )
    )
  )
  WITH CHECK (
    owner_id = iam.current_user_id() OR
    EXISTS (
      SELECT 1
      FROM data.chat_shares s
      JOIN iam.group_members gm ON s.group_id = gm.group_id
      WHERE s.chat_id = id AND gm.user_id = iam.current_user_id() AND s.can_write
    )
  );

ALTER TABLE data.chat_shares ENABLE ROW LEVEL SECURITY;
CREATE POLICY chat_shares_rls_policy ON data.chat_shares FOR ALL TO web_user
  USING (
    EXISTS (
      SELECT 1 FROM data.chats c WHERE c.id = chat_id AND c.owner_id = iam.current_user_id()
    )
  );
How to Use and Extend It
Workflow Example
User A (Alice) calls POST /todos with {"title": "My first todo"}.

The DEFAULT on owner_id sets her as the owner.

The RLS WITH CHECK clause passes because she is the owner.

Alice calls POST /iam.groups with {"name": "Project Team"}. She becomes the owner of this group.

Alice adds User B (Bob) to the group by calling POST /iam.group_members with {"group_id": 1, "user_id": "bob-s-uuid"}.

Alice shares her todo with the group by calling POST /data.todo_shares with {"todo_id": 1, "group_id": 1, "can_write": true}.

Bob can now call GET /todos?id=eq.1 and see Alice's todo. He can also PATCH it because can_write is true. He cannot delete it.

Adding a New Table (data.notes)
It's a simple, repeatable 3-step process:

Create the Tables: Create data.notes (with owner_id) and data.note_shares by copying the todos example.

Grant Permissions: GRANT ALL ON data.notes, data.note_shares TO web_user;

Apply RLS Policies: Copy the RLS policies from data.todos and data.todo_shares, then find-and-replace the table and column names.

This design is highly performant because the iam.current_user_id() is evaluated once per transaction, and the RLS subqueries are simple EXISTS checks that benefit greatly from standard PostgreSQL indexes.


This response is more helpful
Choice B
Of course. Here is a generic, performant, and easy-to-manage IAM system for PostgreSQL and PostgREST that uses JWTs for authentication.

This solution is centered around a dedicated iam schema and a reusable SQL function that enforces Row-Level Security (RLS). This approach ensures that access control is handled directly and securely within the database.

Core Concepts
Ownership: Every protected resource (a row in a table) has an owner_id. This is automatically set to the user's ID (sub claim from the JWT) when the resource is created.

Group Sharing: Resources can optionally be assigned a group_id. If a group_id is set, any user who is a member of that group gains full CRUD access to that specific resource.

Centralized Logic: A single, generic SQL function, iam.check_resource_access, contains all the logic to determine if a user can access a resource. This function checks for both direct ownership and group membership.

Row-Level Security (RLS): PostgreSQL's RLS feature is used to apply this access logic to every query, ensuring that users can only see or modify data they are authorized to access. This is enforced for the web_user role that PostgREST uses for authenticated requests.

SQL Implementation
Here is the complete SQL script to set up the IAM system.

Part 1: Initial Role Setup
These are the standard PostgREST roles you provided.

SQL

CREATE ROLE api_owner NOINHERIT;
CREATE ROLE web_anon NOLOGIN;
CREATE ROLE web_user NOLOGIN;
CREATE ROLE authenticator LOGIN NOINHERIT PASSWORD 'mypass';
GRANT web_anon TO authenticator;
GRANT web_user TO authenticator;
Part 2: Create the IAM Schema and Helper Function
This schema will hold our group tables and the core access logic. The helper function provides an easy way to get the current authenticated user's ID from the JWT.

SQL

CREATE SCHEMA iam;

CREATE FUNCTION iam.get_current_user_id()
RETURNS uuid AS $$
BEGIN
  RETURN (current_setting('request.jwt.claims', true)::jsonb ->> 'sub')::uuid;
EXCEPTION
  WHEN OTHERS THEN
    RETURN '00000000-0000-0000-0000-000000000000'::uuid;
END;
$$ LANGUAGE plpgsql STABLE;

CREATE TABLE iam.groups (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_id uuid NOT NULL DEFAULT iam.get_current_user_id(),
  name text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON iam.groups (owner_id);

CREATE TABLE iam.group_members (
  group_id uuid NOT NULL REFERENCES iam.groups(id) ON DELETE CASCADE,
  user_id uuid NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (group_id, user_id)
);
CREATE INDEX ON iam.group_members (user_id);
Part 3: Create Example Application Tables
Here we set up an app schema with todos and chats tables. Note the addition of the owner_id and group_id columns, which are key to the IAM system.

SQL

CREATE SCHEMA app;

CREATE TABLE app.todos (
  id serial PRIMARY KEY,
  task text NOT NULL,
  done boolean NOT NULL DEFAULT false,
  owner_id uuid NOT NULL DEFAULT iam.get_current_user_id(),
  group_id uuid REFERENCES iam.groups(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON app.todos (owner_id);
CREATE INDEX ON app.todos (group_id);

CREATE TABLE app.chats (
  id serial PRIMARY KEY,
  message text NOT NULL,
  owner_id uuid NOT NULL DEFAULT iam.get_current_user_id(),
  group_id uuid REFERENCES iam.groups(id) ON DELETE SET NULL,
  created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON app.chats (owner_id);
CREATE INDEX ON app.chats (group_id);
Part 4: Create the Generic RLS Function
This is the core of the IAM system. It's a single function that checks if the current user is either the direct owner or a member of the group the resource is assigned to.

SQL

CREATE FUNCTION iam.check_resource_access(_owner_id uuid, _group_id uuid)
RETURNS boolean AS $$
DECLARE
  current_user_id uuid := iam.get_current_user_id();
BEGIN
  IF current_user_id = _owner_id THEN
    RETURN true;
  END IF;

  IF _group_id IS NOT NULL THEN
    RETURN EXISTS (
      SELECT 1
      FROM iam.group_members gm
      WHERE gm.group_id = _group_id AND gm.user_id = current_user_id
    );
  END IF;

  RETURN false;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
Part 5: Apply Security Policies and Grant Permissions
Now we enable RLS and apply policies that use our generic function. We also apply policies to the iam tables themselves so users can manage their own groups. Finally, we grant the necessary permissions to the web_user role.

SQL

ALTER SCHEMA iam OWNER TO api_owner;
ALTER TABLE iam.groups OWNER TO api_owner;
ALTER TABLE iam.group_members OWNER TO api_owner;
ALTER FUNCTION iam.get_current_user_id() OWNER TO api_owner;
ALTER FUNCTION iam.check_resource_access(uuid, uuid) OWNER TO api_owner;

ALTER SCHEMA app OWNER TO api_owner;
ALTER TABLE app.todos OWNER TO api_owner;
ALTER TABLE app.chats OWNER TO api_owner;

ALTER TABLE iam.groups ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow owners to manage their groups" ON iam.groups
  FOR ALL TO web_user
  USING (owner_id = iam.get_current_user_id())
  WITH CHECK (owner_id = iam.get_current_user_id());

ALTER TABLE iam.group_members ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow group owners to manage members" ON iam.group_members
  FOR ALL TO web_user
  USING (EXISTS (SELECT 1 FROM iam.groups WHERE id = group_id AND owner_id = iam.get_current_user_id()))
  WITH CHECK (EXISTS (SELECT 1 FROM iam.groups WHERE id = group_id AND owner_id = iam.get_current_user_id()));

CREATE POLICY "Allow members to view other members" ON iam.group_members
  FOR SELECT TO web_user
  USING (EXISTS (SELECT 1 FROM iam.group_members WHERE group_id = iam.group_members.group_id AND user_id = iam.get_current_user_id()));

ALTER TABLE app.todos ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow CRUD for owners and group members" ON app.todos
  FOR ALL TO web_user
  USING (iam.check_resource_access(owner_id, group_id))
  WITH CHECK (iam.check_resource_access(owner_id, group_id));

ALTER TABLE app.chats ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Allow CRUD for owners and group members" ON app.chats
  FOR ALL TO web_user
  USING (iam.check_resource_access(owner_id, group_id))
  WITH CHECK (iam.check_resource_access(owner_id, group_id));

GRANT USAGE ON SCHEMA app, iam TO web_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA app, iam TO web_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA app TO web_user;
How to Use the System
Let's assume you have two users with the following JWT sub IDs:

User A: 11111111-1111-1111-1111-111111111111

User B: 22222222-2222-2222-2222-222222222222

Example Workflow
User A creates a private todo.

POST /todos with body {"task": "My private task"}

The database automatically sets owner_id to User A's ID. Only User A can read or modify this todo.

User A creates a group.

POST /groups with body {"name": "Project Team"}

This creates a group owned by User A. Let's say its new id is 99999999-9999-9999-9999-999999999999.

User A adds User B to the group.

POST /group_members with body {"group_id": "999...", "user_id": "222..."}

Only User A can do this because they own the group.

User A creates a shared chat message.

POST /chats with body {"message": "Team, let's sync up", "group_id": "999..."}

Now, both User A (as the owner) and User B (as a group member) can read, update, or delete this specific chat message.

How to Add New Protected Tables
Adding a new table to this IAM system is simple and follows a consistent pattern.

Add Columns: Add owner_id and group_id columns to your new table, using the same definitions as the todos and chats examples.

SQL

CREATE TABLE app.new_table (
  id serial PRIMARY KEY,
  -- your other columns
  owner_id uuid NOT NULL DEFAULT iam.get_current_user_id(),
  group_id uuid REFERENCES iam.groups(id) ON DELETE SET NULL
);
Add Indexes: Create indexes on the new owner_id and group_id columns for performance.

SQL

CREATE INDEX ON app.new_table (owner_id);
CREATE INDEX ON app.new_table (group_id);
Apply Policy: Enable RLS and apply the generic policy.

SQL

ALTER TABLE app.new_table ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Allow CRUD for owners and group members" ON app.new_table
  FOR ALL TO web_user
  USING (iam.check_resource_access(owner_id, group_id))
  WITH CHECK (iam.check_resource_access(owner_id, group_id));
Grant Permissions: Grant the web_user role permissions on the new table.

SQL

GRANT SELECT, INSERT, UPDATE, DELETE ON app.new_table TO web_user;
GRANT USAGE, SELECT ON SEQUENCE app.new_table_id_seq TO web_user;