-
-
Notifications
You must be signed in to change notification settings - Fork 131
fix(security): require org membership for app-scoped role_bindings #3094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
riderx
wants to merge
16
commits into
main
Choose a base branch
from
fix/security-ghsa-9976-role-bindings
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+809
−0
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
52f3503
fix(security): require org membership for scoped role_bindings (GHSA-…
cursoragent d5c7816
ci: retrigger pull_request checks
cursoragent d201712
test(db): fix role_bindings membership pgTAP ordering and error code
cursoragent a4f8d62
fix(security): address CodeRabbit role_bindings follow-ups
cursoragent 14d65dd
ci: retrigger pull_request checks
cursoragent d659618
test(db): address cubic P2 threads for role_bindings membership RLS
cursoragent 14c574e
ci: retrigger after cancelled workflow
cursoragent a779b09
ci: retrigger tests (sequential, no concurrent security PRs)
cursoragent e5238d9
test(db): run apikey reject case before outsider gains org membership
cursoragent 6a6e9d2
ci: retrigger after queue drain
cursoragent 4be6a56
ci: retrigger to validate test ordering fix
cursoragent 6bcc1e0
ci: retrigger after cancelled workflow
cursoragent 8f5bf0b
ci: retrigger Run tests (CLI integration cancelled)
cursoragent dfde3a7
ci: retrigger pull_request after Cloudflare plugin flake
cursoragent 6e5ee12
ci: retrigger pull_request Run tests
cursoragent 2cb4128
Merge branch 'main' into fix/security-ghsa-9976-role-bindings
TorichanCapgo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
320 changes: 320 additions & 0 deletions
320
supabase/migrations/20260826080551_role_bindings_require_org_membership.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,320 @@ | ||
| -- GHSA-9976-934w-5whq | ||
| -- role_bindings INSERT/UPDATE only checked that the caller has *_update_user_roles. | ||
| -- Direct PostgREST could grant app/channel/bundle roles to non-members. | ||
| -- Keep caller permission checks. Require the TARGET principal to belong to the org | ||
| -- for scoped bindings. Do not block org-scope user INSERT (first membership). | ||
|
|
||
| -- Execution model (RLS helper, not a user-facing RPC): | ||
| -- - Where: role_bindings INSERT WITH CHECK and UPDATE USING/WITH CHECK. | ||
| -- - How often: once per written row from authenticated callers. | ||
| -- - Roles: EXECUTE granted to authenticated for policy evaluation only; helper | ||
| -- lives in rbac_internal (not PostgREST-exposed). | ||
| -- - Cardinality: bounded indexed lookups on principal_id + org_id; never scans | ||
| -- all role_bindings/apps/orgs. | ||
| -- - Indexes: role_bindings_org_scope_uniq, role_bindings_principal_scope_idx, | ||
| -- groups_pkey, apikeys_rbac_id_key. | ||
|
|
||
| CREATE SCHEMA IF NOT EXISTS rbac_internal; | ||
|
|
||
| CREATE OR REPLACE FUNCTION rbac_internal.role_binding_principal_allowed_for_org( | ||
| p_principal_type text, | ||
| p_principal_id uuid, | ||
| p_org_id uuid, | ||
| p_scope_type text | ||
| ) | ||
| RETURNS boolean | ||
| LANGUAGE sql | ||
| STABLE | ||
| SECURITY DEFINER | ||
| SET search_path = '' | ||
| AS $$ | ||
| SELECT CASE | ||
| WHEN p_principal_type = public.rbac_principal_user() | ||
| AND p_scope_type = public.rbac_scope_org() | ||
| THEN true | ||
| WHEN p_principal_type = public.rbac_principal_user() | ||
| AND p_scope_type IN ( | ||
| public.rbac_scope_app(), | ||
| public.rbac_scope_channel(), | ||
| public.rbac_scope_bundle() | ||
| ) | ||
| THEN EXISTS ( | ||
| SELECT 1 | ||
| FROM public.role_bindings AS membership | ||
| WHERE membership.principal_type = public.rbac_principal_user() | ||
| AND membership.principal_id = p_principal_id | ||
| AND membership.scope_type = public.rbac_scope_org() | ||
| AND membership.org_id = p_org_id | ||
| AND ( | ||
| membership.expires_at IS NULL | ||
| OR membership.expires_at > pg_catalog.now() | ||
| ) | ||
| ) | ||
| WHEN p_principal_type = public.rbac_principal_group() | ||
| THEN EXISTS ( | ||
| SELECT 1 | ||
| FROM public.groups | ||
| WHERE groups.id = p_principal_id | ||
| AND groups.org_id = p_org_id | ||
| ) | ||
| WHEN p_principal_type = public.rbac_principal_apikey() | ||
| THEN EXISTS ( | ||
| SELECT 1 | ||
| FROM public.role_bindings AS membership | ||
| WHERE membership.principal_type = public.rbac_principal_apikey() | ||
| AND membership.principal_id = p_principal_id | ||
| AND membership.scope_type = public.rbac_scope_org() | ||
| AND membership.org_id = p_org_id | ||
| AND ( | ||
| membership.expires_at IS NULL | ||
| OR membership.expires_at > pg_catalog.now() | ||
| ) | ||
| ) | ||
| OR EXISTS ( | ||
| SELECT 1 | ||
| FROM public.apikeys | ||
| WHERE apikeys.rbac_id = p_principal_id | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.role_bindings AS owner_membership | ||
| WHERE owner_membership.principal_type = public.rbac_principal_user() | ||
| AND owner_membership.principal_id = apikeys.user_id | ||
| AND owner_membership.scope_type = public.rbac_scope_org() | ||
| AND owner_membership.org_id = p_org_id | ||
| AND ( | ||
| owner_membership.expires_at IS NULL | ||
| OR owner_membership.expires_at > pg_catalog.now() | ||
| ) | ||
| ) | ||
| ) | ||
| ELSE false | ||
| END | ||
| $$; | ||
|
|
||
| ALTER FUNCTION rbac_internal.role_binding_principal_allowed_for_org(text, uuid, uuid, text) | ||
| OWNER TO postgres; | ||
| REVOKE ALL ON FUNCTION rbac_internal.role_binding_principal_allowed_for_org(text, uuid, uuid, text) | ||
| FROM PUBLIC; | ||
| GRANT USAGE ON SCHEMA rbac_internal TO authenticated, service_role; | ||
| GRANT EXECUTE ON FUNCTION rbac_internal.role_binding_principal_allowed_for_org(text, uuid, uuid, text) | ||
| TO authenticated, service_role; | ||
|
|
||
| COMMENT ON FUNCTION rbac_internal.role_binding_principal_allowed_for_org(text, uuid, uuid, text) IS | ||
| 'RLS helper: target principal may receive a role_binding on this org. User ' | ||
| 'org-scope is always allowed (first membership). User app/channel/bundle ' | ||
| 'requires a non-expired org-scope binding. Group must belong to the org. ' | ||
| 'Apikey must have an org-scope binding or an owner with org-scope membership.'; | ||
|
|
||
| DROP FUNCTION IF EXISTS public.role_binding_principal_allowed_for_org(text, uuid, uuid, text); | ||
|
|
||
| DROP POLICY IF EXISTS "role_bindings_insert" ON public.role_bindings; | ||
| CREATE POLICY "role_bindings_insert" | ||
| ON public.role_bindings | ||
| FOR INSERT | ||
| TO authenticated | ||
| WITH CHECK ( | ||
| ( | ||
| ( | ||
| scope_type = public.rbac_scope_org() | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_org_update_user_roles(), | ||
| org_id, | ||
| NULL::character varying, | ||
| NULL::bigint | ||
| ) | ||
| ) | ||
| OR ( | ||
| scope_type = public.rbac_scope_app() | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.apps | ||
| WHERE apps.id = role_bindings.app_id | ||
| AND role_bindings.org_id = apps.owner_org | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_app_update_user_roles(), | ||
| apps.owner_org, | ||
| apps.app_id, | ||
| NULL::bigint | ||
| ) | ||
| ) | ||
| ) | ||
| OR ( | ||
| scope_type = public.rbac_scope_channel() | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.channels | ||
| WHERE channels.rbac_id = role_bindings.channel_id | ||
| AND role_bindings.org_id = channels.owner_org | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_app_update_user_roles(), | ||
| channels.owner_org, | ||
| channels.app_id, | ||
| channels.id | ||
| ) | ||
| ) | ||
| ) | ||
| OR ( | ||
| scope_type = public.rbac_scope_bundle() | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.app_versions | ||
| JOIN public.apps | ||
| ON apps.app_id = app_versions.app_id | ||
| WHERE app_versions.id = role_bindings.bundle_id | ||
| AND role_bindings.org_id = apps.owner_org | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_app_update_user_roles(), | ||
| apps.owner_org, | ||
| apps.app_id, | ||
| NULL::bigint | ||
| ) | ||
| ) | ||
| ) | ||
| ) | ||
| AND rbac_internal.role_binding_principal_allowed_for_org( | ||
| principal_type, | ||
| principal_id, | ||
| org_id, | ||
| scope_type | ||
| ) | ||
| ); | ||
|
|
||
| DROP POLICY IF EXISTS "role_bindings_update" ON public.role_bindings; | ||
| CREATE POLICY "role_bindings_update" | ||
| ON public.role_bindings | ||
| FOR UPDATE | ||
| TO authenticated | ||
| USING ( | ||
| ( | ||
| ( | ||
| scope_type = public.rbac_scope_org() | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_org_update_user_roles(), | ||
| org_id, | ||
| NULL::character varying, | ||
| NULL::bigint | ||
| ) | ||
| ) | ||
| OR ( | ||
| scope_type = public.rbac_scope_app() | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.apps | ||
| WHERE apps.id = role_bindings.app_id | ||
| AND role_bindings.org_id = apps.owner_org | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_app_update_user_roles(), | ||
| apps.owner_org, | ||
| apps.app_id, | ||
| NULL::bigint | ||
| ) | ||
| ) | ||
| ) | ||
| OR ( | ||
| scope_type = public.rbac_scope_channel() | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.channels | ||
| WHERE channels.rbac_id = role_bindings.channel_id | ||
| AND role_bindings.org_id = channels.owner_org | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_app_update_user_roles(), | ||
| channels.owner_org, | ||
| channels.app_id, | ||
| channels.id | ||
| ) | ||
| ) | ||
| ) | ||
| OR ( | ||
| scope_type = public.rbac_scope_bundle() | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.app_versions | ||
| JOIN public.apps | ||
| ON apps.app_id = app_versions.app_id | ||
| WHERE app_versions.id = role_bindings.bundle_id | ||
| AND role_bindings.org_id = apps.owner_org | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_app_update_user_roles(), | ||
| apps.owner_org, | ||
| apps.app_id, | ||
| NULL::bigint | ||
| ) | ||
| ) | ||
| ) | ||
| ) | ||
| ) | ||
| WITH CHECK ( | ||
| ( | ||
| ( | ||
| scope_type = public.rbac_scope_org() | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_org_update_user_roles(), | ||
| org_id, | ||
| NULL::character varying, | ||
| NULL::bigint | ||
| ) | ||
| ) | ||
| OR ( | ||
| scope_type = public.rbac_scope_app() | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.apps | ||
| WHERE apps.id = role_bindings.app_id | ||
| AND role_bindings.org_id = apps.owner_org | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_app_update_user_roles(), | ||
| apps.owner_org, | ||
| apps.app_id, | ||
| NULL::bigint | ||
| ) | ||
| ) | ||
| ) | ||
| OR ( | ||
| scope_type = public.rbac_scope_channel() | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.channels | ||
| WHERE channels.rbac_id = role_bindings.channel_id | ||
| AND role_bindings.org_id = channels.owner_org | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_app_update_user_roles(), | ||
| channels.owner_org, | ||
| channels.app_id, | ||
| channels.id | ||
| ) | ||
| ) | ||
| ) | ||
| OR ( | ||
| scope_type = public.rbac_scope_bundle() | ||
| AND EXISTS ( | ||
| SELECT 1 | ||
| FROM public.app_versions | ||
| JOIN public.apps | ||
| ON apps.app_id = app_versions.app_id | ||
| WHERE app_versions.id = role_bindings.bundle_id | ||
| AND role_bindings.org_id = apps.owner_org | ||
| AND public.rbac_check_permission_request( | ||
| public.rbac_perm_app_update_user_roles(), | ||
| apps.owner_org, | ||
| apps.app_id, | ||
| NULL::bigint | ||
| ) | ||
| ) | ||
| ) | ||
| ) | ||
| AND rbac_internal.role_binding_principal_allowed_for_org( | ||
| principal_type, | ||
| principal_id, | ||
| org_id, | ||
| scope_type | ||
| ) | ||
| ); | ||
|
|
||
| COMMENT ON POLICY "role_bindings_insert" ON public.role_bindings IS | ||
| 'Caller needs *_update_user_roles. Scoped bindings require org_id to match the ' | ||
| 'resource owner org and the target principal to belong to that org.'; | ||
|
|
||
| COMMENT ON POLICY "role_bindings_update" ON public.role_bindings IS | ||
| 'Same caller permission, owner-org binding, and target-membership checks as ' | ||
| 'role_bindings_insert.'; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.