/* * ------------------------------------------------------- * Hierarchy-Aware Role & Permission Functions + RLS Policies * * Extends the flat has_role_on_account model to support * Verband → Subverein → Verein hierarchy. * * Design decisions: * - Parents can READ children (top-down visibility) * - Children CANNOT see parents or siblings * - Writes remain strictly per-account (no cascade) * - Only team accounts participate in hierarchy * ------------------------------------------------------- */ -- ------------------------------------------------------- -- Core: check role on account OR any ancestor account -- ------------------------------------------------------- CREATE OR REPLACE FUNCTION public.has_role_on_account_or_ancestor( target_account_id uuid, account_role varchar(50) DEFAULT NULL ) RETURNS boolean LANGUAGE sql SECURITY DEFINER SET search_path = '' AS $$ SELECT EXISTS ( SELECT 1 FROM public.accounts_memberships membership WHERE membership.user_id = (SELECT auth.uid()) AND membership.account_id IN ( SELECT public.get_account_ancestors(target_account_id) ) AND ( membership.account_role = has_role_on_account_or_ancestor.account_role OR has_role_on_account_or_ancestor.account_role IS NULL ) ); $$; GRANT EXECUTE ON FUNCTION public.has_role_on_account_or_ancestor(uuid, varchar) TO authenticated, service_role; -- ------------------------------------------------------- -- Core: check permission on account OR any ancestor -- ------------------------------------------------------- CREATE OR REPLACE FUNCTION public.has_permission_or_ancestor( user_id uuid, target_account_id uuid, permission_name public.app_permissions ) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN RETURN EXISTS ( SELECT 1 FROM public.accounts_memberships am JOIN public.role_permissions rp ON am.account_role = rp.role WHERE am.user_id = has_permission_or_ancestor.user_id AND am.account_id IN ( SELECT public.get_account_ancestors(target_account_id) ) AND rp.permission = has_permission_or_ancestor.permission_name ); END; $$; GRANT EXECUTE ON FUNCTION public.has_permission_or_ancestor(uuid, uuid, public.app_permissions) TO authenticated, service_role; -- ------------------------------------------------------- -- Utility: get all accounts visible to the current user -- through hierarchy (accounts they belong to + descendants) -- ------------------------------------------------------- CREATE OR REPLACE FUNCTION public.get_user_visible_accounts() RETURNS SETOF uuid LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$ SELECT DISTINCT d.id FROM public.accounts_memberships am CROSS JOIN LATERAL public.get_account_descendants(am.account_id) d(id) WHERE am.user_id = (SELECT auth.uid()); $$; GRANT EXECUTE ON FUNCTION public.get_user_visible_accounts() TO authenticated, service_role; -- ------------------------------------------------------- -- Utility: list child accounts of a given account -- ------------------------------------------------------- CREATE OR REPLACE FUNCTION public.get_child_accounts(parent_id uuid) RETURNS TABLE ( id uuid, name varchar, slug text, parent_account_id uuid, created_at timestamptz ) LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' AS $$ BEGIN -- Only return children if caller has a role on the parent account IF NOT public.has_role_on_account(get_child_accounts.parent_id) THEN RETURN; END IF; RETURN QUERY SELECT a.id, a.name, a.slug, a.parent_account_id, a.created_at FROM public.accounts a WHERE a.parent_account_id = get_child_accounts.parent_id AND a.is_personal_account = false; END; $$; GRANT EXECUTE ON FUNCTION public.get_child_accounts(uuid) TO authenticated, service_role; -- ------------------------------------------------------- -- RLS: Allow parent accounts to read child account rows -- -- We add PERMISSIVE policies alongside existing ones. -- Existing: has_role_on_account(account_id) — direct membership -- New: has_role_on_account_or_ancestor(account_id) — hierarchy -- -- Only applied to SELECT (reads). Writes stay per-account. -- ------------------------------------------------------- -- Members table: parent can see child members DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'members' AND policyname = 'members_hierarchy_read' ) THEN EXECUTE 'CREATE POLICY members_hierarchy_read ON public.members FOR SELECT TO authenticated USING (public.has_role_on_account_or_ancestor(account_id))'; END IF; END $$; -- Events table: parent can see child events DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'events' AND policyname = 'events_hierarchy_read' ) THEN EXECUTE 'CREATE POLICY events_hierarchy_read ON public.events FOR SELECT TO authenticated USING (public.has_role_on_account_or_ancestor(account_id))'; END IF; END $$; -- Courses table: parent can see child courses DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'courses' AND policyname = 'courses_hierarchy_read' ) THEN EXECUTE 'CREATE POLICY courses_hierarchy_read ON public.courses FOR SELECT TO authenticated USING (public.has_role_on_account_or_ancestor(account_id))'; END IF; END $$; -- Member clubs (Verbandsverwaltung): parent can see child clubs DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'member_clubs' AND policyname = 'member_clubs_hierarchy_read' ) THEN EXECUTE 'CREATE POLICY member_clubs_hierarchy_read ON public.member_clubs FOR SELECT TO authenticated USING (public.has_role_on_account_or_ancestor(account_id))'; END IF; END $$; -- Accounts table: allow reading child accounts in hierarchy DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = 'accounts' AND policyname = 'accounts_hierarchy_read' ) THEN EXECUTE 'CREATE POLICY accounts_hierarchy_read ON public.accounts FOR SELECT TO authenticated USING ( id IN (SELECT public.get_user_visible_accounts()) )'; END IF; END $$;