Budget Bee uses three separate database users for security:
Main User
Auth Admin
Subscription Admin
-- Main application userCREATE ROLE budgetbee_user WITH LOGIN PASSWORD 'your-secure-password';-- Grant connectionGRANT CONNECT ON DATABASE budgetbee TO budgetbee_user;
This user is used for general application operations.
-- Authentication admin userCREATE ROLE auth_admin WITH LOGIN PASSWORD 'auth-admin-password' BYPASSRLS;-- Grant connectionGRANT CONNECT ON DATABASE budgetbee TO auth_admin;
Used by Better Auth for managing:
User accounts
Sessions
Organizations
Members and invitations
BYPASSRLS allows this user to bypass row-level security policies. Keep credentials secure.
-- Subscription admin userCREATE ROLE subscription_admin WITH LOGIN PASSWORD 'subscription-admin-password' BYPASSRLS;-- Grant connectionGRANT CONNECT ON DATABASE budgetbee TO subscription_admin;
Used for subscription billing operations via Polar webhooks.
-- Anonymous role (not logged in)CREATE ROLE anon NOLOGIN;-- Authenticated role (logged in users)CREATE ROLE authenticated NOLOGIN;-- Grant schema accessGRANT USAGE ON SCHEMA public TO anon, authenticated, auth_admin, subscription_admin;-- Authenticated users have full access to most tablesGRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO authenticated;-- But cannot use TRIGGER or TRUNCATEREVOKE TRIGGER, TRUNCATE ON ALL TABLES IN SCHEMA public FROM authenticated;
RLS policies ensure users can only access their own data:
-- Example: Transaction access policyCREATE POLICY limit_transactions_select ON transactions FOR SELECTTO authenticated USING ( -- Personal transactions ( organization_id IS NULL AND user_id = uid () ) -- OR organization transactions (with role check) OR ( organization_id = org_id () AND ( check_ac_current ('transaction', 'list') OR check_ac_current ('transaction', 'get') ) ));
-- Get current user ID from JWTCREATE OR REPLACE FUNCTION uid () RETURNS text AS $$DECLARE user_id text;BEGIN user_id := COALESCE( current_setting('request.jwt.claims', true)::jsonb ->> 'sub', current_setting('request.jwt.claims', true)::jsonb ->> 'user_id' ); RETURN user_id;END$$ LANGUAGE plpgsql STABLE;
-- Check if role has permission for action on resourceCREATE OR REPLACE FUNCTION check_ac ( p_role TEXT, p_resource TEXT, p_action TEXT) RETURNS BOOLEAN AS $$DECLARE v_allowed_actions TEXT[];BEGIN CASE p_role WHEN 'owner', 'admin', 'editor' THEN CASE p_resource WHEN 'transaction', 'subscription', 'accounts' THEN v_allowed_actions := ARRAY['list', 'get', 'create', 'update', 'delete']; ELSE RETURN FALSE; END CASE; WHEN 'viewer' THEN CASE p_resource WHEN 'transaction', 'subscription', 'accounts' THEN v_allowed_actions := ARRAY['list', 'get']; ELSE RETURN FALSE; END CASE; ELSE RETURN FALSE; END CASE; RETURN p_action = ANY(v_allowed_actions);END$$ LANGUAGE plpgsql STABLE;
-- Aggregate transaction data for widgetsCREATE OR REPLACE FUNCTION get_transaction_aggregate( group_by_metric TEXT, time_interval TEXT, agg_function TEXT)RETURNS TABLE ( period TIMESTAMP, metric_value TEXT, aggregate_result NUMERIC) AS $$BEGIN IF agg_function NOT IN ('sum', 'avg', 'count', 'min', 'max') THEN RAISE EXCEPTION 'Invalid aggregate'; END IF; RETURN QUERY EXECUTE format( 'SELECT date_trunc(%s, transaction_date) AS period, CAST(%s AS TEXT) AS metric_value, %s(amount)::NUMERIC AS aggregate_result FROM transactions GROUP BY 1, 2 ORDER BY 1 DESC, 2 ASC', quote_literal(time_interval), quote_ident(group_by_metric), quote_ident(agg_function) );END;$$ LANGUAGE plpgsql;
-- Transaction queriesCREATE INDEX idx_transactions_user_id ON transactions(user_id);CREATE INDEX idx_transactions_org_id ON transactions(organization_id);CREATE INDEX idx_transactions_date ON transactions(transaction_date DESC);CREATE INDEX idx_transactions_category ON transactions(category_id);-- Category queriesCREATE INDEX idx_categories_user_id ON categories(user_id);CREATE INDEX idx_categories_org_id ON categories(organization_id);-- Session lookupsCREATE INDEX idx_sessions_user_id ON sessions(user_id);