diff --git a/extension/keyhippo--0.0.40.sql b/extension/keyhippo--0.0.40.sql index 8e1a052..5f12c1b 100644 --- a/extension/keyhippo--0.0.40.sql +++ b/extension/keyhippo--0.0.40.sql @@ -74,11 +74,11 @@ BEGIN RAISE EXCEPTION '[KeyHippo] Invalid key description'; END IF; -- Generate a secure random API key - new_api_key := encode(extensions.gen_random_bytes(32), 'base64'); + new_api_key := encode(extensions.gen_random_bytes(64), 'base64'); new_api_key_id := extensions.gen_random_uuid (); -- Store the hashed key INSERT INTO keyhippo.api_keys (id, user_id, key_hash, description) - VALUES (new_api_key_id, authenticated_user_id, extensions.crypt(new_api_key, extensions.gen_salt('bf')), key_description); + VALUES (new_api_key_id, authenticated_user_id, extensions.crypt(new_api_key, extensions.gen_salt('bf', 8)), key_description); -- Return the API key and its ID RETURN QUERY SELECT @@ -248,10 +248,10 @@ CREATE POLICY "Allow user to update their own API keys" ON keyhippo.api_keys -- RBAC + ABAC Implementation -- ================================================================ -- Create RBAC Schema -CREATE SCHEMA IF NOT EXISTS keyhippo_rbac AUTHORIZATION postgres; +CREATE SCHEMA IF NOT EXISTS keyhippo_rbac; -- Create ABAC Schema -CREATE SCHEMA IF NOT EXISTS keyhippo_abac AUTHORIZATION postgres; +CREATE SCHEMA IF NOT EXISTS keyhippo_abac; -- ------------------------------- -- RBAC Tables @@ -266,10 +266,11 @@ CREATE TABLE IF NOT EXISTS keyhippo_rbac.groups ( -- Create Roles Table CREATE TABLE IF NOT EXISTS keyhippo_rbac.roles ( id uuid PRIMARY KEY DEFAULT extensions.gen_random_uuid (), - name text UNIQUE NOT NULL, + name text NOT NULL, description text, group_id uuid NOT NULL REFERENCES keyhippo_rbac.groups (id) ON DELETE CASCADE, - parent_role_id uuid REFERENCES keyhippo_rbac.roles (id) ON DELETE SET NULL + parent_role_id uuid REFERENCES keyhippo_rbac.roles (id) ON DELETE SET NULL, + UNIQUE (name, group_id) ); -- Create Permissions Table @@ -314,15 +315,15 @@ CREATE TABLE IF NOT EXISTS keyhippo_abac.policies ( id uuid PRIMARY KEY DEFAULT extensions.gen_random_uuid (), name text UNIQUE NOT NULL, description text, - policy JSONB NOT NULL + policy jsonb NOT NULL ); -- ------------------------------- -- RBAC Functions -- ------------------------------- --- Function: add_user_to_group -CREATE OR REPLACE FUNCTION keyhippo_rbac.add_user_to_group (p_user_id uuid, p_group_id uuid, p_role_name text) - RETURNS VOID +-- Function: assign_role_to_user +CREATE OR REPLACE FUNCTION keyhippo_rbac.assign_role_to_user (p_user_id uuid, p_group_id uuid, p_role_name text) + RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_temp @@ -330,7 +331,7 @@ CREATE OR REPLACE FUNCTION keyhippo_rbac.add_user_to_group (p_user_id uuid, p_gr DECLARE v_role_id uuid; BEGIN - -- Fetch the role ID based on role name and group + -- Get the role ID SELECT id INTO v_role_id FROM @@ -338,15 +339,16 @@ BEGIN WHERE name = p_role_name AND group_id = p_group_id; - IF NOT FOUND THEN - RAISE EXCEPTION 'Role "%" not found in group ID %', p_role_name, p_group_id; + IF v_role_id IS NULL THEN + RAISE EXCEPTION 'Role % not found in the specified group', p_role_name; END IF; - -- Insert into user_group_roles if not exists + -- Insert or update the user_group_roles entry INSERT INTO keyhippo_rbac.user_group_roles (user_id, group_id, role_id) VALUES (p_user_id, p_group_id, v_role_id) - ON CONFLICT - DO NOTHING; - -- Update user claims cache + ON CONFLICT (user_id, group_id, role_id) + DO UPDATE SET + role_id = EXCLUDED.role_id; + -- Update the claims cache PERFORM keyhippo_rbac.update_user_claims_cache (p_user_id); END; @@ -354,31 +356,45 @@ $$; -- Function: update_user_claims_cache CREATE OR REPLACE FUNCTION keyhippo_rbac.update_user_claims_cache (p_user_id uuid) - RETURNS VOID + RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_temp AS $$ +DECLARE + v_claims jsonb := '{}'::jsonb; + v_group_id uuid; + v_role_names text[]; BEGIN + -- Delete existing claims for the user DELETE FROM keyhippo_rbac.claims_cache WHERE user_id = p_user_id; - INSERT INTO keyhippo_rbac.claims_cache (user_id, rbac_claims) + -- Gather roles for each group + FOR v_group_id, + v_role_names IN SELECT - p_user_id, - jsonb_object_agg(group_id::text, roles) - FROM ( - SELECT - ugr.group_id, - jsonb_agg(DISTINCT r.name) AS roles - FROM - keyhippo_rbac.user_group_roles ugr - JOIN keyhippo_rbac.roles r ON ugr.role_id = r.id - WHERE - ugr.user_id = p_user_id - GROUP BY - ugr.group_id) AS group_roles -GROUP BY - p_user_id; + g.id, + array_agg(DISTINCT r.name) + FROM + keyhippo_rbac.user_group_roles ugr + JOIN keyhippo_rbac.roles r ON ugr.role_id = r.id + JOIN keyhippo_rbac.groups g ON ugr.group_id = g.id + WHERE + ugr.user_id = p_user_id + GROUP BY + g.id LOOP + v_claims := v_claims || jsonb_build_object(v_group_id::text, v_role_names); + END LOOP; + -- Insert new claims + INSERT INTO keyhippo_rbac.claims_cache (user_id, rbac_claims) + VALUES (p_user_id, v_claims); + -- If no claims were inserted (user has no roles), ensure an empty claims cache entry exists + IF NOT FOUND THEN + INSERT INTO keyhippo_rbac.claims_cache (user_id, rbac_claims) + VALUES (p_user_id, '{}') + ON CONFLICT (user_id) + DO NOTHING; + END IF; END; $$; @@ -401,7 +417,76 @@ BEGIN END; $$; -GRANT EXECUTE ON FUNCTION keyhippo_abac.set_user_attribute (uuid, text, jsonb) TO authenticated; +CREATE OR REPLACE FUNCTION keyhippo_abac.create_policy (p_name text, p_description text, p_policy jsonb) + RETURNS void + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_temp + AS $$ +BEGIN + INSERT INTO keyhippo_abac.policies (name, description, POLICY) + VALUES (p_name, p_description, p_policy) + ON CONFLICT (name) + DO UPDATE SET + description = EXCLUDED.description, POLICY = EXCLUDED.policy; +END; +$$; + +-- Function: check_abac_policy +CREATE OR REPLACE FUNCTION keyhippo_abac.check_abac_policy (p_user_id uuid, p_policy jsonb) + RETURNS boolean + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_temp + AS $$ +DECLARE + v_attribute_value jsonb; + v_policy_attribute text; + v_policy_value jsonb; +BEGIN + v_policy_attribute := p_policy ->> 'attribute'; + v_policy_value := p_policy -> 'value'; + SELECT + attributes -> v_policy_attribute INTO v_attribute_value + FROM + keyhippo_abac.user_attributes + WHERE + user_id = p_user_id; + IF v_attribute_value IS NULL THEN + RETURN FALSE; + END IF; + RETURN CASE WHEN p_policy ->> 'type' = 'attribute_equals' THEN + v_attribute_value = v_policy_value + WHEN p_policy ->> 'type' = 'attribute_contains' THEN + v_attribute_value @> v_policy_value + ELSE + FALSE + END; +END; +$$; + +-- Function: evaluate_policies +CREATE OR REPLACE FUNCTION keyhippo_abac.evaluate_policies (p_user_id uuid) + RETURNS boolean + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = pg_temp + AS $$ +DECLARE + policy_record RECORD; +BEGIN + FOR policy_record IN + SELECT + * + FROM + keyhippo_abac.policies LOOP + IF NOT keyhippo_abac.check_abac_policy (p_user_id, policy_record.policy) THEN + RETURN FALSE; + END IF; + END LOOP; + RETURN TRUE; +END; +$$; -- ------------------------------- -- RLS Policies @@ -447,45 +532,48 @@ CREATE POLICY "abac_user_attributes_access" ON keyhippo_abac.user_attributes -- ------------------------------- -- Permissions and Grants -- ------------------------------- --- Grant USAGE on RBAC and ABAC Schemas to Authenticated Role +-- Grant USAGE on schemas GRANT USAGE ON SCHEMA keyhippo_rbac TO authenticated; GRANT USAGE ON SCHEMA keyhippo_abac TO authenticated; --- Grant SELECT, INSERT, UPDATE, DELETE on All RBAC Tables to Authenticated Role -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA keyhippo_rbac TO authenticated; +-- Grant SELECT on RBAC, ABAC tables +GRANT SELECT ON ALL TABLES IN SCHEMA keyhippo_rbac TO authenticated; --- Grant SELECT, INSERT, UPDATE, DELETE on All ABAC Tables to Authenticated Role -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA keyhippo_abac TO authenticated; +GRANT SELECT ON ALL TABLES IN SCHEMA keyhippo_abac TO authenticated; --- Grant EXECUTE on RBAC Functions to Authenticated Role -GRANT EXECUTE ON FUNCTION keyhippo_rbac.add_user_to_group (uuid, uuid, text) TO authenticated; +-- Grant EXECUTE on RBAC functions +GRANT EXECUTE ON FUNCTION keyhippo_rbac.assign_role_to_user (uuid, uuid, text) TO authenticated; GRANT EXECUTE ON FUNCTION keyhippo_rbac.update_user_claims_cache (uuid) TO authenticated; --- Grant EXECUTE on ABAC Functions to Authenticated Role +-- Grant EXECUTE on ABAC functions GRANT EXECUTE ON FUNCTION keyhippo_abac.set_user_attribute (uuid, text, jsonb) TO authenticated; +GRANT EXECUTE ON FUNCTION keyhippo_abac.create_policy (text, text, jsonb) TO authenticated; + +GRANT EXECUTE ON FUNCTION keyhippo_abac.check_abac_policy (uuid, jsonb) TO authenticated; + +GRANT EXECUTE ON FUNCTION keyhippo_abac.evaluate_policies (uuid) TO authenticated; + -- ------------------------------- -- Indexes for Performance -- ------------------------------- --- GIN Index on user_attributes.attributes -CREATE INDEX IF NOT EXISTS idx_user_attributes_attributes ON keyhippo_abac.user_attributes USING GIN (attributes); +CREATE INDEX IF NOT EXISTS idx_user_attributes_gin ON keyhippo_abac.user_attributes USING gin (attributes); --- GIN Index on claims_cache.rbac_claims -CREATE INDEX IF NOT EXISTS idx_claims_cache_rbac_claims ON keyhippo_rbac.claims_cache USING GIN (rbac_claims); +CREATE INDEX IF NOT EXISTS idx_claims_cache_gin ON keyhippo_rbac.claims_cache USING gin (rbac_claims); -- ------------------------------- -- Default Data Insertion -- ------------------------------- --- Inserting Default Groups +-- Insert default groups INSERT INTO keyhippo_rbac.groups (name, description) VALUES ('Admin Group', 'Group with administrative privileges'), ('User Group', 'Group with standard user privileges') ON CONFLICT (name) DO NOTHING; --- Inserting Default Roles +-- Insert default roles INSERT INTO keyhippo_rbac.roles (name, description, group_id) SELECT 'Admin', @@ -495,7 +583,8 @@ FROM keyhippo_rbac.groups WHERE name = 'Admin Group' -ON CONFLICT (name) +ON CONFLICT (name, + group_id) DO NOTHING; INSERT INTO keyhippo_rbac.roles (name, description, group_id) @@ -507,70 +596,65 @@ FROM keyhippo_rbac.groups WHERE name = 'User Group' -ON CONFLICT (name) +ON CONFLICT (name, + group_id) DO NOTHING; --- Inserting Default Permissions +-- Insert default permissions INSERT INTO keyhippo_rbac.permissions (name, description) VALUES ('read', 'Read Permission'), ('write', 'Write Permission'), - ('delete', 'Delete Permission') + ('delete', 'Delete Permission'), + ('manage_policies', 'Manage ABAC Policies') ON CONFLICT (name) DO NOTHING; --- Mapping Roles to Permissions --- Admin Role: All permissions +-- Assign permissions to roles INSERT INTO keyhippo_rbac.role_permissions (role_id, permission_id) SELECT r.id, p.id FROM keyhippo_rbac.roles r - JOIN keyhippo_rbac.permissions p ON p.name IN ('read', 'write', 'delete') + CROSS JOIN keyhippo_rbac.permissions p WHERE r.name = 'Admin' ON CONFLICT DO NOTHING; --- User Role: read and write permissions INSERT INTO keyhippo_rbac.role_permissions (role_id, permission_id) SELECT r.id, p.id FROM keyhippo_rbac.roles r - JOIN keyhippo_rbac.permissions p ON p.name IN ('read', 'write') + CROSS JOIN keyhippo_rbac.permissions p WHERE r.name = 'User' + AND p.name IN ('read', 'write') ON CONFLICT DO NOTHING; -- ------------------------------- --- Triggers for Automatic Claims Cache Updates +-- Triggers -- ------------------------------- --- Function: trigger_update_claims_cache CREATE OR REPLACE FUNCTION keyhippo_rbac.trigger_update_claims_cache () RETURNS TRIGGER - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = pg_temp AS $$ BEGIN PERFORM keyhippo_rbac.update_user_claims_cache (NEW.user_id); RETURN NEW; END; -$$; +$$ +LANGUAGE plpgsql +SECURITY DEFINER; --- Trigger: after_insert_update_user_group_roles -CREATE TRIGGER after_insert_update_user_group_roles - AFTER INSERT OR UPDATE ON keyhippo_rbac.user_group_roles +CREATE TRIGGER after_user_group_roles_change + AFTER INSERT OR UPDATE OR DELETE ON keyhippo_rbac.user_group_roles FOR EACH ROW EXECUTE FUNCTION keyhippo_rbac.trigger_update_claims_cache (); --- ------------------------------- --- Notifications and Final Setup --- ------------------------------- --- Notify pgRest to reload configuration if necessary +-- Notify pgRest to reload configuration NOTIFY pgrst, 'reload config';