Skip to content

Setting Supabase Users, Custom Claims and RBAC

Using this as a starting point:

Base functions

CREATE FUNCTION update_updated_at_column() RETURNS trigger
    LANGUAGE plpgsql
AS
$$
BEGIN
   NEW.updated_at = NOW();
   RETURN NEW;
END;
$$;

Create tables to track user roles and permissions

Create table users

CREATE TABLE public.users
(
    id                 uuid NOT NULL
        PRIMARY KEY        REFERENCES auth.users,
    name               TEXT,
    avatar_url         TEXT,
    metadata           jsonb,
    updated_at         TIMESTAMP WITH TIME ZONE,
    updated_by         uuid,
    email              TEXT,
    deleted_at         TIMESTAMP WITH TIME ZONE,
    banned_until       TIMESTAMP WITH TIME ZONE,
    email_confirmed_at TIMESTAMP WITH TIME ZONE
);

CREATE TRIGGER users_bu_set_updated_at
    BEFORE UPDATE
    ON public.users
    FOR EACH ROW
EXECUTE PROCEDURE update_updated_at_column();

Create functions to handle new/updated users

CREATE FUNCTION handle_new_user() RETURNS trigger
    SECURITY DEFINER
    SET search_path = ''
    LANGUAGE plpgsql
AS
$$
BEGIN
    INSERT INTO public.users (id,
                              name,
                              avatar_url,
                              email,
                              deleted_at,
                              banned_until,
                              email_confirmed_at)
    VALUES (new.id,
            new.raw_user_meta_data ->> 'full_name',
            new.raw_user_meta_data ->> 'avatar_url',
            new.email,
            new.deleted_at,
            new.banned_until,
            new.email_confirmed_at);
    RETURN new;
END;
$$;
CREATE FUNCTION handle_auth_user_update() RETURNS trigger
    SECURITY DEFINER
    SET search_path = ''
    LANGUAGE plpgsql
AS
$$
BEGIN
    UPDATE public.users
    SET email              = new.email,
        deleted_at         = new.deleted_at,
        banned_until       = new.banned_until,
        email_confirmed_at = new.email_confirmed_at
    WHERE id = new.id;
    RETURN new;
END;
$$;

Create triggers for auth.users

CREATE TRIGGER on_auth_user_created
    AFTER INSERT
    ON auth.users
    FOR EACH ROW
EXECUTE PROCEDURE public.handle_new_user();
CREATE TRIGGER on_auth_user_updated
    AFTER UPDATE
        OF email, deleted_at, banned_until, email_confirmed_at
    ON auth.users
    FOR EACH ROW
EXECUTE PROCEDURE public.handle_auth_user_update();

Create table roles

CREATE TABLE public.roles
(
    name  TEXT                         NOT NULL
        PRIMARY KEY,
    color TEXT DEFAULT 'primary'::TEXT NOT NULL
        CONSTRAINT valid_color
            CHECK (color = ANY
                   (ARRAY ['primary'::TEXT, 'red'::TEXT, 'orange'::TEXT, 'amber'::TEXT, 'yellow'::TEXT, 'lime'::TEXT, 'green'::TEXT, 'emerald'::TEXT, 'teal'::TEXT, 'cyan'::TEXT, 'sky'::TEXT, 'blue'::TEXT, 'indigo'::TEXT, 'violet'::TEXT, 'purple'::TEXT, 'fuchsia'::TEXT, 'pink'::TEXT, 'rose'::TEXT]))
);

COMMENT ON TABLE public.roles IS 'Application roles';

Create table permissions

CREATE TABLE public.permissions
(
    action   TEXT NOT NULL,
    resource TEXT NOT NULL,
    PRIMARY KEY (action, resource)
);

COMMENT ON TABLE public.permissions IS 'Application permissions in the format "resource:action"';

Create table role_permissions

CREATE TABLE public.role_permissions
(
    role     TEXT NOT NULL
        REFERENCES roles
            ON UPDATE CASCADE ON DELETE CASCADE,
    action   TEXT NOT NULL,
    resource TEXT NOT NULL,
    PRIMARY KEY (role, action, resource),
    FOREIGN KEY (action, resource) REFERENCES permissions
        ON DELETE CASCADE
);

COMMENT ON TABLE public.role_permissions IS 'Permissions assigned to each role';

Create table user_roles

CREATE TABLE public.user_roles
(
    user_id uuid NOT NULL
        REFERENCES users
            ON DELETE CASCADE,
    role    TEXT NOT NULL
        REFERENCES roles
            ON UPDATE CASCADE ON DELETE CASCADE,
    PRIMARY KEY (user_id, role)
);

COMMENT ON TABLE public.user_roles IS 'Roles assigned to each user';

Create table user_permissions (for additional permissions)

CREATE TABLE public.user_permissions
(
    user_id  uuid NOT NULL
        REFERENCES users
            ON DELETE CASCADE,
    action   TEXT NOT NULL,
    resource TEXT NOT NULL,
    PRIMARY KEY (user_id, action, resource),
    FOREIGN KEY (action, resource) REFERENCES permissions
        ON DELETE CASCADE
);

COMMENT ON TABLE public.user_permissions IS 'Custom permissions assigned directly to each user';

Create RBAC functions

CREATE OR REPLACE FUNCTION public.has_permission(requested_permission text) RETURNS boolean
    STABLE
    SECURITY DEFINER
    SET search_path = ''
    LANGUAGE plpgsql
AS
$$
DECLARE
    bind_permissions INT;
    user_roles       jsonb;
    user_permissions jsonb;
BEGIN
    -- Fetch user roles and custom permissions
    SELECT (auth.jwt() ->> 'user_roles')::jsonb INTO user_roles;
    SELECT (auth.jwt() ->> 'user_permissions')::jsonb INTO user_permissions;

    -- Check for matching permission in roles
    SELECT count(*)
    INTO bind_permissions
    FROM public.role_permissions rp
    WHERE rp.action || ':' || rp.resource = requested_permission
      AND rp.role = ANY (array(SELECT jsonb_array_elements_text(user_roles)));

    -- Check for custom permissions
    IF bind_permissions = 0 THEN
        SELECT count(*)
        INTO bind_permissions
        FROM jsonb_array_elements_text(user_permissions) AS perm
        WHERE perm = requested_permission;
    END IF;

    RETURN bind_permissions > 0;
END;
$$;
CREATE OR REPLACE FUNCTION public.has_role(requested_role text) RETURNS boolean
    STABLE
    SECURITY DEFINER
    SET search_path = ''
    LANGUAGE plpgsql
AS
$$
DECLARE
    user_roles jsonb;
BEGIN
    -- Fetch the roles from the JWT claims
    SELECT (auth.jwt() ->> 'user_roles')::jsonb INTO user_roles;

    -- Check if the requested role is in the list of user roles
    RETURN requested_role = ANY (array(SELECT jsonb_array_elements_text(user_roles)));
END;
$$;

Add policies

CREATE POLICY "Public profiles are viewable by everyone." ON public.users
    AS PERMISSIVE
    FOR SELECT    USING TRUE;

CREATE POLICY "Users can insert their own profile." ON public.users
    AS PERMISSIVE
    FOR INSERT    WITH CHECK ((SELECT auth.uid() AS uid) = id);

CREATE POLICY "Users can update own profile." ON public.users
    AS PERMISSIVE
    FOR UPDATE    USING ((SELECT auth.uid() AS uid) = id);

CREATE POLICY "Allow UPDATE to role 'admin'" ON public.users
    AS PERMISSIVE
    FOR UPDATE    TO authenticated
    USING (SELECT has_role('admin'::TEXT) AS has_role);
CREATE POLICY "Allow SELECT with permission 'rbac:read'" ON public.roles
    AS PERMISSIVE
    FOR SELECT    TO authenticated
    USING (SELECT has_permission('rbac:read'::TEXT) AS has_permission);

CREATE POLICY "Allow ALL to role 'admin'" ON public.roles
    AS PERMISSIVE
    FOR ALL    TO authenticated
    USING (SELECT has_role('admin'::TEXT) AS has_role);
CREATE POLICY "Allow SELECT with permission 'rbac:read'" ON public.permissions
    AS PERMISSIVE
    FOR SELECT    TO authenticated
    USING (SELECT has_permission('rbac:read'::TEXT) AS has_permission);

CREATE POLICY "Allow ALL to role 'admin'" ON public.permissions
    AS PERMISSIVE
    FOR ALL    TO authenticated
    USING (SELECT has_role('admin'::TEXT) AS has_role);
CREATE POLICY "Allow auth admin to read role permissions" ON public.role_permissions
    AS PERMISSIVE
    FOR SELECT    TO supabase_auth_admin
    USING TRUE;

CREATE POLICY "Allow SELECT with permission 'rbac:read'" ON public.role_permissions
    AS PERMISSIVE
    FOR SELECT    TO authenticated
    USING (SELECT has_permission('rbac:read'::TEXT) AS has_permission);

CREATE POLICY "Allow ALL to role 'admin'" ON public.role_permissions
    AS PERMISSIVE
    FOR ALL    TO authenticated
    USING (SELECT has_role('admin'::TEXT) AS has_role);
CREATE POLICY "Allow auth admin to read user roles" ON public.user_roles
    AS PERMISSIVE
    FOR SELECT    TO supabase_auth_admin
    USING TRUE;

CREATE POLICY "Allow SELECT with permission 'rbac:read'" ON public.user_roles
    AS PERMISSIVE
    FOR SELECT    TO authenticated
    USING (SELECT has_permission('rbac:read'::TEXT) AS has_permission);

CREATE POLICY "Allow ALL to role 'admin'" ON public.user_roles
    AS PERMISSIVE
    FOR ALL    TO authenticated
    USING (SELECT has_role('admin'::TEXT) AS has_role);
CREATE POLICY "Allow auth admin to read user permissions" ON public.user_permissions
    AS PERMISSIVE
    FOR SELECT    TO supabase_auth_admin
    USING TRUE;

CREATE POLICY "Allow SELECT with permission 'rbac:read'" ON public.user_permissions
    AS PERMISSIVE
    FOR SELECT    TO authenticated
    USING (SELECT has_permission('rbac:read'::TEXT) AS has_permission);

CREATE POLICY "Allow ALL to role 'admin'" ON public.user_permissions
    AS PERMISSIVE
    FOR ALL    TO authenticated
    USING (SELECT has_role('admin'::TEXT) AS has_role);

Access Token Hook

CREATE OR REPLACE FUNCTION custom_access_token_hook(event jsonb) RETURNS jsonb
    STABLE
    SET search_path = ""
    LANGUAGE plpgsql
AS
$$
DECLARE
    claims               jsonb;
    user_roles           jsonb DEFAULT '[]'::jsonb;
    role_permissions     jsonb DEFAULT '[]'::jsonb;
    custom_permissions   jsonb DEFAULT '[]'::jsonb;
    combined_permissions jsonb DEFAULT '[]'::jsonb;
BEGIN
    -- Fetch all roles for the user
    SELECT coalesce(jsonb_agg(role), '[]'::jsonb)
    INTO user_roles
    FROM public.user_roles
    WHERE user_id = (event ->> 'user_id')::uuid;

    -- If no roles are found, assign the default role 'user'
    IF user_roles = '[]'::jsonb THEN
        user_roles := jsonb_build_array('user');
    END IF;

    -- Fetch all permissions associated with the user's roles
    SELECT coalesce(jsonb_agg(concat(rp.resource, ':', rp.action)), '[]'::jsonb)
    INTO role_permissions
    FROM public.role_permissions rp
    WHERE rp.role = ANY (array(SELECT jsonb_array_elements_text(coalesce(user_roles, '[]'::jsonb))));

    -- Fetch all custom permissions for the user
    SELECT coalesce(jsonb_agg(concat(up.resource, ':', up.action)), '[]'::jsonb)
    INTO custom_permissions
    FROM public.user_permissions up
    WHERE up.user_id = (event ->> 'user_id')::uuid;

    -- Combine role permissions and custom permissions and remove duplicates
    combined_permissions := (SELECT jsonb_agg(DISTINCT permission)
                             FROM jsonb_array_elements_text(role_permissions || custom_permissions) AS permission);

    claims := event -> 'claims';

    -- Add user roles and permissions to claims
    claims := jsonb_set(claims, '{user_roles}', coalesce(user_roles, '[]'::jsonb));
    claims := jsonb_set(claims, '{user_permissions}', coalesce(combined_permissions, '[]'::jsonb));

    -- Update event with new claims
    event := jsonb_set(event, '{claims}', claims);

    RETURN event;
END;
$$;
GRANT USAGE ON SCHEMA public TO supabase_auth_admin;

GRANT EXECUTE ON FUNCTION public.custom_access_token_hook
TO supabase_auth_admin;

REVOKE EXECUTE ON FUNCTION public.custom_access_token_hook
FROM authenticated, anon, public;
GRANT ALL ON TABLE public.user_roles TO supabase_auth_admin;

REVOKE ALL ON TABLE public.user_roles FROM anon, public;

GRANT ALL ON TABLE public.user_permissions TO supabase_auth_admin;

REVOKE ALL ON TABLE public.user_permissions FROM anon, public;
CREATE POLICY "Allow auth admin to read user roles" ON public.user_roles
AS PERMISSIVE FOR SELECT TO supabase_auth_admin
USING (TRUE)

Enable the hook#

In the dashboard, navigate to Authentication > Hooks (Beta) and select the appropriate PostgreSQL function from the dropdown menu.