Add account hierarchy framework with migrations, RLS policies, and UI components
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* -------------------------------------------------------
|
||||
* Account Hierarchy Support
|
||||
*
|
||||
* Adds parent_account_id to accounts for Verein/Subverein/Verband
|
||||
* hierarchy. This migration is additive — no behavior change until
|
||||
* hierarchy-aware RLS policies are added in a future migration.
|
||||
* -------------------------------------------------------
|
||||
*/
|
||||
|
||||
-- Add nullable parent reference (team accounts only)
|
||||
ALTER TABLE public.accounts
|
||||
ADD COLUMN IF NOT EXISTS parent_account_id uuid
|
||||
REFERENCES public.accounts(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_accounts_parent_account_id
|
||||
ON public.accounts(parent_account_id);
|
||||
|
||||
-- Prevent personal accounts from having a parent (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'accounts_personal_no_parent'
|
||||
) THEN
|
||||
ALTER TABLE public.accounts
|
||||
ADD CONSTRAINT accounts_personal_no_parent
|
||||
CHECK (is_personal_account = false OR parent_account_id IS NULL);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Prevent an account from being its own parent (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'accounts_no_self_parent'
|
||||
) THEN
|
||||
ALTER TABLE public.accounts
|
||||
ADD CONSTRAINT accounts_no_self_parent
|
||||
CHECK (id <> parent_account_id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Trigger: prevent hierarchy cycles on insert/update
|
||||
-- -------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION kit.prevent_account_hierarchy_cycle()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.parent_account_id IS NOT NULL THEN
|
||||
-- Walk up from the proposed parent; if we find NEW.id, it's a cycle
|
||||
IF EXISTS (
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, parent_account_id
|
||||
FROM public.accounts
|
||||
WHERE id = NEW.parent_account_id
|
||||
UNION ALL
|
||||
SELECT a.id, a.parent_account_id
|
||||
FROM public.accounts a
|
||||
JOIN ancestors anc ON a.id = anc.parent_account_id
|
||||
)
|
||||
CYCLE id SET is_cycle USING path
|
||||
SELECT 1 FROM ancestors WHERE id = NEW.id AND NOT is_cycle
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Setting parent_account_id would create a hierarchy cycle';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE TRIGGER prevent_account_hierarchy_cycle
|
||||
BEFORE INSERT OR UPDATE OF parent_account_id
|
||||
ON public.accounts
|
||||
FOR EACH ROW
|
||||
WHEN (NEW.parent_account_id IS NOT NULL)
|
||||
EXECUTE FUNCTION kit.prevent_account_hierarchy_cycle();
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Helper: get all descendant account IDs (recursive, cycle-safe)
|
||||
-- Restricted to service_role — called via RLS helper functions
|
||||
-- -------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.get_account_descendants(root_id uuid)
|
||||
RETURNS SETOF uuid
|
||||
LANGUAGE sql STABLE
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, parent_account_id
|
||||
FROM public.accounts WHERE id = root_id
|
||||
UNION ALL
|
||||
SELECT a.id, a.parent_account_id
|
||||
FROM public.accounts a
|
||||
JOIN tree t ON a.parent_account_id = t.id
|
||||
)
|
||||
CYCLE id SET is_cycle USING path
|
||||
SELECT id FROM tree WHERE NOT is_cycle;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_account_descendants(uuid)
|
||||
TO service_role;
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Helper: get all ancestor account IDs (walk up, cycle-safe)
|
||||
-- Restricted to service_role — called via RLS helper functions
|
||||
-- -------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.get_account_ancestors(child_id uuid)
|
||||
RETURNS SETOF uuid
|
||||
LANGUAGE sql STABLE
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, parent_account_id
|
||||
FROM public.accounts WHERE id = child_id
|
||||
UNION ALL
|
||||
SELECT a.id, a.parent_account_id
|
||||
FROM public.accounts a
|
||||
JOIN tree t ON a.id = t.parent_account_id
|
||||
)
|
||||
CYCLE id SET is_cycle USING path
|
||||
SELECT id FROM tree WHERE NOT is_cycle;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_account_ancestors(uuid)
|
||||
TO service_role;
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Helper: get the depth of the hierarchy (0 = root, cycle-safe)
|
||||
-- Restricted to service_role
|
||||
-- -------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.get_account_depth(account_id uuid)
|
||||
RETURNS integer
|
||||
LANGUAGE sql STABLE
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, parent_account_id, 0 AS depth
|
||||
FROM public.accounts
|
||||
WHERE id = account_id
|
||||
UNION ALL
|
||||
SELECT a.id, a.parent_account_id, t.depth + 1
|
||||
FROM public.accounts a
|
||||
JOIN tree t ON a.id = t.parent_account_id
|
||||
)
|
||||
CYCLE id SET is_cycle USING path
|
||||
SELECT MAX(depth) FROM tree WHERE NOT is_cycle;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_account_depth(uuid)
|
||||
TO service_role;
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* -------------------------------------------------------
|
||||
* 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 id FROM 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 id FROM 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 $$;
|
||||
Reference in New Issue
Block a user