From db5934d0195a5e4aa85407ce7c8024f369ef7fcd Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Fri, 3 Apr 2026 12:02:57 +0530 Subject: [PATCH 01/16] feat: migrate SDK to use SetProjectMemberRole and RemoveProjectMember RPCs Replace policy-based member management (createPolicyForProject, listPolicies, deletePolicy, createPolicy) with the new atomic RPCs. React SDK: - project-members.tsx: addMember/addTeam use SetProjectMemberRole - project-member-columns.tsx: updateRole uses SetProjectMemberRole - remove-project-member-dialog.tsx: uses RemoveProjectMember - add-service-account-dialog.tsx: uses SetProjectMemberRole - manage-service-user-projects-dialog.tsx: uses both RPCs Admin SDK: - use-add-project-members.tsx: uses SetProjectMemberRole - remove-member.tsx: uses RemoveProjectMember - assign-role.tsx: uses SetProjectMemberRole Co-Authored-By: Claude Opus 4.6 (1M context) --- .../details/projects/members/assign-role.tsx | 65 ++++------------- .../projects/members/remove-member.tsx | 28 +++----- .../projects/use-add-project-members.tsx | 23 +++--- .../manage-service-user-projects-dialog.tsx | 54 +++++--------- .../list/add-service-account-dialog.tsx | 24 +++---- .../details/project-member-columns.tsx | 70 ++++--------------- .../projects/details/project-members.tsx | 28 ++++---- .../details/remove-project-member-dialog.tsx | 43 ++++-------- 8 files changed, 103 insertions(+), 232 deletions(-) diff --git a/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx b/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx index 5fc180f8a..b79a0e79c 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx @@ -15,14 +15,10 @@ import type { } from "@raystack/proton/frontier"; import { FrontierServiceQueries, - FrontierService, - ListPoliciesRequestSchema, - DeletePolicyRequestSchema, - CreatePolicyRequestSchema, + SetProjectMemberRoleRequestSchema, } from "@raystack/proton/frontier"; import { create } from "@bufbuild/protobuf"; -import { useMutation, useTransport } from "@connectrpc/connect-query"; -import { createClient } from "@connectrpc/connect"; +import { useMutation } from "@connectrpc/connect-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -50,8 +46,6 @@ export const AssignRole = ({ onRoleUpdate, onClose, }: AssignRoleProps) => { - const transport = useTransport(); - const { handleSubmit, watch, @@ -64,12 +58,8 @@ export const AssignRole = ({ resolver: zodResolver(formSchema), }); - const { mutateAsync: deletePolicy } = useMutation( - FrontierServiceQueries.deletePolicy, - ); - - const { mutateAsync: createPolicy } = useMutation( - FrontierServiceQueries.createPolicy, + const { mutateAsync: setProjectMemberRole } = useMutation( + FrontierServiceQueries.setProjectMemberRole, ); const roleIds = watch("roleIds"); @@ -97,43 +87,18 @@ export const AssignRole = ({ const onSubmit = async (data: FormData) => { try { - const client = createClient(FrontierService, transport); - const policiesResp = await client.listPolicies( - create(ListPoliciesRequestSchema, { - projectId: projectId, - userId: user?.id, - }), - ); - const policies = policiesResp.policies || []; - - const removedRolesPolicies = policies.filter( - (policy) => !(policy.roleId && data.roleIds.has(policy.roleId)), - ); - await Promise.all( - removedRolesPolicies.map((policy) => - deletePolicy( - create(DeletePolicyRequestSchema, { id: policy.id || "" }), - ), - ), - ); - - const resource = `app/project:${projectId}`; - const principal = `app/user:${user?.id}`; - const assignedRolesArr = Array.from(data.roleIds); - await Promise.all( - assignedRolesArr.map((roleId) => - createPolicy( - create(CreatePolicyRequestSchema, { - body: { - roleId, - resource, - principal, - }, - }), - ), - ), - ); + + for (const roleId of assignedRolesArr) { + await setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { + projectId, + principalId: user?.id || "", + principalType: "app/user", + roleId, + }), + ); + } if (onRoleUpdate) { onRoleUpdate({ diff --git a/web/sdk/admin/views/organizations/details/projects/members/remove-member.tsx b/web/sdk/admin/views/organizations/details/projects/members/remove-member.tsx index dbf1af86b..de3cbcc01 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/remove-member.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/remove-member.tsx @@ -1,14 +1,11 @@ import { useState } from "react"; import { - FrontierService, FrontierServiceQueries, - ListPoliciesRequestSchema, - DeletePolicyRequestSchema, + RemoveProjectMemberRequestSchema, type SearchProjectUsersResponse_ProjectUser, } from "@raystack/proton/frontier"; import { create } from "@bufbuild/protobuf"; -import { useMutation, useTransport } from "@connectrpc/connect-query"; -import { createClient } from "@connectrpc/connect"; +import { useMutation } from "@connectrpc/connect-query"; import styles from "./members.module.css"; import { Button, Dialog, Flex, Text, toast } from "@raystack/apsara"; @@ -29,31 +26,22 @@ export const RemoveMember = ({ }: RemoveMemberProps) => { const t = useTerminology(); const [isSubmitting, setIsSubmitting] = useState(false); - const transport = useTransport(); - const { mutateAsync: deletePolicy } = useMutation( - FrontierServiceQueries.deletePolicy, + const { mutateAsync: removeProjectMember } = useMutation( + FrontierServiceQueries.removeProjectMember, ); async function onSubmit() { try { if (!user) return; setIsSubmitting(true); - const client = createClient(FrontierService, transport); - const policiesResp = await client.listPolicies( - create(ListPoliciesRequestSchema, { + await removeProjectMember( + create(RemoveProjectMemberRequestSchema, { projectId: projectId, - userId: user?.id, + principalId: user.id, + principalType: "app/user", }), ); - const policies = policiesResp.policies || []; - await Promise.all( - policies.map((policy) => - deletePolicy( - create(DeletePolicyRequestSchema, { id: policy.id || "" }), - ), - ), - ); if (onRemove) { onRemove(user); diff --git a/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx b/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx index a81e6c2a5..002d406ff 100644 --- a/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx +++ b/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx @@ -3,7 +3,7 @@ import { OrganizationContext } from "../contexts/organization-context"; import { toast } from "@raystack/apsara"; import { DEFAULT_ROLES } from "../../../../utils/constants"; import { useQuery, useMutation } from "@connectrpc/connect-query"; -import { FrontierServiceQueries, ListProjectUsersRequestSchema, CreatePolicyRequestSchema } from "@raystack/proton/frontier"; +import { FrontierServiceQueries, ListProjectUsersRequestSchema, SetProjectMemberRoleRequestSchema } from "@raystack/proton/frontier"; import { create } from "@bufbuild/protobuf"; import { handleConnectError } from "~/utils/error"; import { useTerminology } from "../../../../hooks/useTerminology"; @@ -48,23 +48,20 @@ export function useAddProjectMembers({ projectId }: useAddProjectMembersProps) { : nonMembers; }, [nonMembers, searchQuery]); - const { mutateAsync: createPolicy } = useMutation( - FrontierServiceQueries.createPolicy, + const { mutateAsync: setProjectMemberRole } = useMutation( + FrontierServiceQueries.setProjectMemberRole, ); const addMember = useCallback( async (userId: string) => { if (!userId || !projectId) return; try { - const principal = `app/user:${userId}`; - const resource = `app/project:${projectId}`; - await createPolicy( - create(CreatePolicyRequestSchema, { - body: { - roleId: DEFAULT_ROLES.PROJECT_VIEWER, - principal, - resource, - }, + await setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { + projectId, + principalId: userId, + principalType: "app/user", + roleId: DEFAULT_ROLES.PROJECT_VIEWER, }), ); toast.success(`${memberLabel} added`); @@ -79,7 +76,7 @@ export function useAddProjectMembers({ projectId }: useAddProjectMembersProps) { }); } }, - [projectId, createPolicy, refetch, projectMembers, memberLabel], + [projectId, setProjectMemberRole, refetch, projectMembers, memberLabel], ); return { diff --git a/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx b/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx index 5adae4f57..2fb43fc99 100644 --- a/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx +++ b/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx @@ -20,12 +20,9 @@ import { FrontierServiceQueries, ListServiceUserProjectsRequestSchema, ListOrganizationProjectsRequestSchema, - CreatePolicyForProjectRequestSchema, - CreatePolicyForProjectBodySchema, - ListPoliciesRequestSchema, - DeletePolicyRequestSchema, - Project, - Policy + SetProjectMemberRoleRequestSchema, + RemoveProjectMemberRequestSchema, + Project } from '@raystack/proton/frontier'; import { orderBy } from 'lodash'; @@ -156,16 +153,12 @@ export default function ManageServiceUserProjectsDialog({ setAddedProjectsMap(permMap); }, [addedProjects]); - const { mutateAsync: createPolicyForProject } = useMutation( - FrontierServiceQueries.createPolicyForProject + const { mutateAsync: setProjectMemberRole } = useMutation( + FrontierServiceQueries.setProjectMemberRole ); - const { mutateAsync: listPolicies } = useMutation( - FrontierServiceQueries.listPolicies - ); - - const { mutateAsync: deletePolicy } = useMutation( - FrontierServiceQueries.deletePolicy + const { mutateAsync: removeProjectMember } = useMutation( + FrontierServiceQueries.removeProjectMember ); const onAccessChange = useCallback( @@ -177,14 +170,12 @@ export default function ManageServiceUserProjectsDialog({ })); if (value) { - const principal = `${PERMISSIONS.ServiceUserPrincipal}:${serviceUserId}`; - await createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { + await setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { projectId, - body: create(CreatePolicyForProjectBodySchema, { - roleId: PERMISSIONS.RoleProjectOwner, - principal - }) + principalId: serviceUserId, + principalType: PERMISSIONS.ServiceUserPrincipal, + roleId: PERMISSIONS.RoleProjectOwner }) ); setAddedProjectsMap(prev => ({ @@ -192,24 +183,13 @@ export default function ManageServiceUserProjectsDialog({ [projectId]: { value: true, isLoading: false } })); } else { - const policiesResp = await listPolicies( - create(ListPoliciesRequestSchema, { + await removeProjectMember( + create(RemoveProjectMemberRequestSchema, { projectId, - userId: serviceUserId, - orgId: '', - roleId: '', - groupId: '' + principalId: serviceUserId, + principalType: PERMISSIONS.ServiceUserPrincipal }) ); - const policies = policiesResp?.policies || []; - const deletePromises = policies.map((p: Policy) => - deletePolicy( - create(DeletePolicyRequestSchema, { - id: p.id - }) - ) - ); - await Promise.all(deletePromises); setAddedProjectsMap(prev => ({ ...prev, [projectId]: { value: false, isLoading: false } @@ -224,7 +204,7 @@ export default function ManageServiceUserProjectsDialog({ })); } }, - [serviceUserId, createPolicyForProject, listPolicies, deletePolicy] + [serviceUserId, setProjectMemberRole, removeProjectMember] ); const columns = getColumns({ diff --git a/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx b/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx index c47f0dbc4..73d399b24 100644 --- a/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx +++ b/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx @@ -19,14 +19,13 @@ import { orderBy } from 'lodash'; import { FrontierServiceQueries, CreateServiceUserRequestSchema, - CreatePolicyForProjectRequestSchema, + SetProjectMemberRoleRequestSchema, CreateServiceUserTokenRequestSchema, ListOrganizationServiceUsersRequestSchema, ListOrganizationProjectsRequestSchema, ListServiceUserTokensRequestSchema, ListServiceUserTokensResponseSchema, - ServiceUserRequestBodySchema, - CreatePolicyForProjectBodySchema + ServiceUserRequestBodySchema } from '@raystack/proton/frontier'; import { PERMISSIONS } from '~/utils'; import { useQueryClient } from '@tanstack/react-query'; @@ -103,8 +102,8 @@ export const AddServiceAccountDialog = ({ FrontierServiceQueries.createServiceUser ); - const { mutateAsync: createPolicyForProject } = useMutation( - FrontierServiceQueries.createPolicyForProject + const { mutateAsync: setProjectMemberRole } = useMutation( + FrontierServiceQueries.setProjectMemberRole ); const { mutateAsync: createServiceUserToken } = useMutation( @@ -128,15 +127,12 @@ export const AddServiceAccountDialog = ({ const serviceUserId = serviceUserResponse.serviceuser?.id; if (!serviceUserId) return; - const principal = `${PERMISSIONS.ServiceUserPrincipal}:${serviceUserId}`; - - await createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { + await setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { projectId: data.project_id, - body: create(CreatePolicyForProjectBodySchema, { - roleId: PERMISSIONS.RoleProjectOwner, - principal - }) + principalId: serviceUserId, + principalType: PERMISSIONS.ServiceUserPrincipal, + roleId: PERMISSIONS.RoleProjectOwner }) ); @@ -189,7 +185,7 @@ export const AddServiceAccountDialog = ({ [ orgId, createServiceUser, - createPolicyForProject, + setProjectMemberRole, createServiceUserToken, queryClient, transport, diff --git a/web/sdk/react/views/projects/details/project-member-columns.tsx b/web/sdk/react/views/projects/details/project-member-columns.tsx index d943a3203..69075c6cd 100644 --- a/web/sdk/react/views/projects/details/project-member-columns.tsx +++ b/web/sdk/react/views/projects/details/project-member-columns.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import { DotsHorizontalIcon, TrashIcon, @@ -16,12 +16,10 @@ import { type DataTableColumnDef, getAvatarColor } from '@raystack/apsara'; -import { useQuery, useMutation } from '@connectrpc/connect-query'; +import { useMutation } from '@connectrpc/connect-query'; import { FrontierServiceQueries, - ListPoliciesRequestSchema, - DeletePolicyRequestSchema, - CreatePolicyRequestSchema, + SetProjectMemberRoleRequestSchema, type Role, type User, type Group @@ -157,34 +155,13 @@ const MembersActions = ({ onRemoveMember?.(member?.id as string); } - const { data: policiesData, refetch: refetchPolicies, error: policiesError } = useQuery( - FrontierServiceQueries.listPolicies, - create(ListPoliciesRequestSchema, { - projectId: projectId, - userId: member.isTeam ? undefined : (member.id as string), - groupId: member.isTeam ? (member.id as string) : undefined - }), - { enabled: !!projectId && !!member?.id && !!canUpdateProject } - ); - - useEffect(() => { - if (policiesError) { - toast.error('Something went wrong', { description: (policiesError as Error).message }); - } - }, [policiesError]); - - const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); - - const { mutateAsync: deletePolicy } = useMutation( - FrontierServiceQueries.deletePolicy, - { - onError: (err: Error) => - toast.error('Something went wrong', { description: err.message }) - } - ); - const { mutateAsync: createPolicy } = useMutation( - FrontierServiceQueries.createPolicy, + const { mutateAsync: setMemberRole } = useMutation( + FrontierServiceQueries.setProjectMemberRole, { + onSuccess: () => { + refetch(); + toast.success('Project member role updated'); + }, onError: (err: Error) => toast.error('Something went wrong', { description: err.message }) } @@ -192,30 +169,14 @@ const MembersActions = ({ async function updateRole(role: Role) { try { - const resource = `app/project:${projectId}`; - const principal = member.isTeam - ? `app/group:${member?.id}` - : `app/user:${member?.id}`; - - await Promise.all( - (policies || []).map(p => - deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) - ) - ); - - await createPolicy( - create(CreatePolicyRequestSchema, { - body: { - roleId: role.id as string, - title: (role.title || role.name) as string, - resource, - principal - } + await setMemberRole( + create(SetProjectMemberRoleRequestSchema, { + projectId: projectId, + principalId: member?.id as string, + principalType: member.isTeam ? 'app/group' : 'app/user', + roleId: role.id as string }) ); - await refetchPolicies(); - refetch(); - toast.success('Project member role updated'); } catch (err) { const message = (err as Error)?.message || 'Failed to update role'; toast.error('Something went wrong', { description: message }); @@ -252,4 +213,3 @@ const MembersActions = ({ ) : null; }; - diff --git a/web/sdk/react/views/projects/details/project-members.tsx b/web/sdk/react/views/projects/details/project-members.tsx index a3c273f8b..ca455d3cc 100644 --- a/web/sdk/react/views/projects/details/project-members.tsx +++ b/web/sdk/react/views/projects/details/project-members.tsx @@ -36,7 +36,7 @@ import { useQuery, useMutation } from '@connectrpc/connect-query'; import { FrontierServiceQueries, ListOrganizationUsersRequestSchema, - CreatePolicyForProjectRequestSchema, + SetProjectMemberRoleRequestSchema, type Group, type User, type Role @@ -233,8 +233,8 @@ const AddMemberDropdown = ({ setQuery(e.target.value); } - const { mutate: createPolicyForProject, isPending: isCreatingPolicy } = useMutation( - FrontierServiceQueries.createPolicyForProject, + const { mutate: setProjectMemberRole, isPending: isCreatingPolicy } = useMutation( + FrontierServiceQueries.setProjectMemberRole, { onSuccess: () => { toast.success('Member added'); @@ -249,29 +249,31 @@ const AddMemberDropdown = ({ const addMember = useCallback( (userId: string) => { if (!userId || !organization?.id || !projectId) return; - const principal = `${PERMISSIONS.UserNamespace}:${userId}`; - createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { + setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { projectId: projectId, - body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + principalId: userId, + principalType: PERMISSIONS.UserNamespace, + roleId: PERMISSIONS.RoleProjectViewer }) ); }, - [createPolicyForProject, organization?.id, projectId] + [setProjectMemberRole, organization?.id, projectId] ); const addTeam = useCallback( (teamId: string) => { if (!teamId || !organization?.id || !projectId) return; - const principal = `${PERMISSIONS.GroupNamespace}:${teamId}`; - createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { + setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { projectId: projectId, - body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + principalId: teamId, + principalType: PERMISSIONS.GroupNamespace, + roleId: PERMISSIONS.RoleProjectViewer }) ); }, - [createPolicyForProject, organization?.id, projectId] + [setProjectMemberRole, organization?.id, projectId] ); return ( diff --git a/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx b/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx index 6e6685f38..23a7f6a65 100644 --- a/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx +++ b/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx @@ -1,9 +1,7 @@ 'use client'; import { Button, Flex, Text, toast, Image, Dialog } from '@raystack/apsara'; -import { useEffect, useMemo } from 'react'; import { - useQuery, useMutation, createConnectQueryKey, useTransport @@ -11,8 +9,7 @@ import { import { useQueryClient } from '@tanstack/react-query'; import { FrontierServiceQueries, - ListPoliciesRequestSchema, - DeletePolicyRequestSchema, + RemoveProjectMemberRequestSchema, ListProjectUsersRequestSchema, ListProjectGroupsRequestSchema } from '@raystack/proton/frontier'; @@ -25,13 +22,15 @@ export interface RemoveProjectMemberDialogProps { onOpenChange?: (value: boolean) => void; projectId: string; memberId: string; + memberType?: 'user' | 'group'; } export const RemoveProjectMemberDialog = ({ open, onOpenChange, projectId, - memberId + memberId, + memberType = 'user' }: RemoveProjectMemberDialogProps) => { const queryClient = useQueryClient(); const transport = useTransport(); @@ -40,25 +39,8 @@ export const RemoveProjectMemberDialog = ({ onOpenChange?.(value); }; - const { data: policiesData, error: policiesError } = useQuery( - FrontierServiceQueries.listPolicies, - create(ListPoliciesRequestSchema, { - projectId: projectId || '', - userId: memberId || '' - }), - { enabled: !!projectId && !!memberId && open } - ); - - useEffect(() => { - if (policiesError) { - toast.error('Something went wrong', { description: (policiesError as Error).message }); - } - }, [policiesError]); - - const policies = useMemo(() => policiesData?.policies ?? [], [policiesData]); - - const { mutateAsync: deletePolicy, isPending } = useMutation( - FrontierServiceQueries.deletePolicy, + const { mutateAsync: removeProjectMember, isPending } = useMutation( + FrontierServiceQueries.removeProjectMember, { onError: (err: Error) => toast.error('Something went wrong', { description: err.message }) @@ -67,10 +49,12 @@ export const RemoveProjectMemberDialog = ({ async function onConfirm() { try { - await Promise.all( - (policies || []).map(p => - deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) - ) + await removeProjectMember( + create(RemoveProjectMemberRequestSchema, { + projectId: projectId, + principalId: memberId, + principalType: memberType === 'group' ? 'app/group' : 'app/user' + }) ); if (projectId) { await queryClient.invalidateQueries({ @@ -99,7 +83,7 @@ export const RemoveProjectMemberDialog = ({ handleOpenChange(false); toast.success('Member removed'); } catch (error) { - console.error('Failed to delete policies:', error); + console.error('Failed to remove member:', error); } } @@ -162,4 +146,3 @@ export const RemoveProjectMemberDialog = ({ ); }; - From 26a8aa467dd4435a295fbb8a4cfe1dfe5c23127f Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Fri, 3 Apr 2026 12:21:45 +0530 Subject: [PATCH 02/16] fix: use role UUIDs instead of string names for project member operations Fetch project-scoped roles via listRoles and resolve the role UUID before calling SetProjectMemberRole. Consistent with the role change dropdown which already uses UUIDs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../projects/use-add-project-members.tsx | 22 +++++++++++++++---- .../manage-service-user-projects-dialog.tsx | 18 ++++++++++++++- .../list/add-service-account-dialog.tsx | 18 ++++++++++++++- .../projects/details/project-members.tsx | 17 +++++++++----- 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx b/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx index 002d406ff..473cd45b6 100644 --- a/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx +++ b/web/sdk/admin/views/organizations/details/projects/use-add-project-members.tsx @@ -3,7 +3,7 @@ import { OrganizationContext } from "../contexts/organization-context"; import { toast } from "@raystack/apsara"; import { DEFAULT_ROLES } from "../../../../utils/constants"; import { useQuery, useMutation } from "@connectrpc/connect-query"; -import { FrontierServiceQueries, ListProjectUsersRequestSchema, SetProjectMemberRoleRequestSchema } from "@raystack/proton/frontier"; +import { FrontierServiceQueries, ListProjectUsersRequestSchema, ListRolesRequestSchema, SetProjectMemberRoleRequestSchema } from "@raystack/proton/frontier"; import { create } from "@bufbuild/protobuf"; import { handleConnectError } from "~/utils/error"; import { useTerminology } from "../../../../hooks/useTerminology"; @@ -48,20 +48,34 @@ export function useAddProjectMembers({ projectId }: useAddProjectMembersProps) { : nonMembers; }, [nonMembers, searchQuery]); + const { data: rolesData } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + state: "enabled", + scopes: ["app/project"], + }), + { enabled: !!projectId } + ); + + const viewerRoleId = useMemo( + () => rolesData?.roles?.find((r) => r.name === DEFAULT_ROLES.PROJECT_VIEWER)?.id ?? "", + [rolesData], + ); + const { mutateAsync: setProjectMemberRole } = useMutation( FrontierServiceQueries.setProjectMemberRole, ); const addMember = useCallback( async (userId: string) => { - if (!userId || !projectId) return; + if (!userId || !projectId || !viewerRoleId) return; try { await setProjectMemberRole( create(SetProjectMemberRoleRequestSchema, { projectId, principalId: userId, principalType: "app/user", - roleId: DEFAULT_ROLES.PROJECT_VIEWER, + roleId: viewerRoleId, }), ); toast.success(`${memberLabel} added`); @@ -76,7 +90,7 @@ export function useAddProjectMembers({ projectId }: useAddProjectMembersProps) { }); } }, - [projectId, setProjectMemberRole, refetch, projectMembers, memberLabel], + [projectId, setProjectMemberRole, refetch, projectMembers, memberLabel, viewerRoleId], ); return { diff --git a/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx b/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx index 2fb43fc99..9b7a978c0 100644 --- a/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx +++ b/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx @@ -20,6 +20,7 @@ import { FrontierServiceQueries, ListServiceUserProjectsRequestSchema, ListOrganizationProjectsRequestSchema, + ListRolesRequestSchema, SetProjectMemberRoleRequestSchema, RemoveProjectMemberRequestSchema, Project @@ -153,6 +154,20 @@ export default function ManageServiceUserProjectsDialog({ setAddedProjectsMap(permMap); }, [addedProjects]); + const { data: rolesData } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + state: 'enabled', + scopes: [PERMISSIONS.ProjectNamespace] + }), + { enabled: open } + ); + + const ownerRoleId = useMemo( + () => rolesData?.roles?.find(r => r.name === PERMISSIONS.RoleProjectOwner)?.id ?? '', + [rolesData] + ); + const { mutateAsync: setProjectMemberRole } = useMutation( FrontierServiceQueries.setProjectMemberRole ); @@ -170,12 +185,13 @@ export default function ManageServiceUserProjectsDialog({ })); if (value) { + if (!ownerRoleId) throw new Error('Project owner role not found'); await setProjectMemberRole( create(SetProjectMemberRoleRequestSchema, { projectId, principalId: serviceUserId, principalType: PERMISSIONS.ServiceUserPrincipal, - roleId: PERMISSIONS.RoleProjectOwner + roleId: ownerRoleId }) ); setAddedProjectsMap(prev => ({ diff --git a/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx b/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx index 73d399b24..afeb5dabc 100644 --- a/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx +++ b/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx @@ -23,6 +23,7 @@ import { CreateServiceUserTokenRequestSchema, ListOrganizationServiceUsersRequestSchema, ListOrganizationProjectsRequestSchema, + ListRolesRequestSchema, ListServiceUserTokensRequestSchema, ListServiceUserTokensResponseSchema, ServiceUserRequestBodySchema @@ -98,6 +99,20 @@ export const AddServiceAccountDialog = ({ return orderBy(list, ['title'], ['asc']); }, [projectsData]); + const { data: rolesData } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + state: 'enabled', + scopes: [PERMISSIONS.ProjectNamespace] + }), + { enabled: open } + ); + + const ownerRoleId = useMemo( + () => rolesData?.roles?.find(r => r.name === PERMISSIONS.RoleProjectOwner)?.id ?? '', + [rolesData] + ); + const { mutateAsync: createServiceUser } = useMutation( FrontierServiceQueries.createServiceUser ); @@ -127,12 +142,13 @@ export const AddServiceAccountDialog = ({ const serviceUserId = serviceUserResponse.serviceuser?.id; if (!serviceUserId) return; + if (!ownerRoleId) throw new Error('Project owner role not found'); await setProjectMemberRole( create(SetProjectMemberRoleRequestSchema, { projectId: data.project_id, principalId: serviceUserId, principalType: PERMISSIONS.ServiceUserPrincipal, - roleId: PERMISSIONS.RoleProjectOwner + roleId: ownerRoleId }) ); diff --git a/web/sdk/react/views/projects/details/project-members.tsx b/web/sdk/react/views/projects/details/project-members.tsx index ca455d3cc..31cee7669 100644 --- a/web/sdk/react/views/projects/details/project-members.tsx +++ b/web/sdk/react/views/projects/details/project-members.tsx @@ -233,6 +233,11 @@ const AddMemberDropdown = ({ setQuery(e.target.value); } + const viewerRole = useMemo( + () => (roles as Role[]).find((r: Role) => r.name === PERMISSIONS.RoleProjectViewer), + [roles] + ); + const { mutate: setProjectMemberRole, isPending: isCreatingPolicy } = useMutation( FrontierServiceQueries.setProjectMemberRole, { @@ -248,32 +253,32 @@ const AddMemberDropdown = ({ const addMember = useCallback( (userId: string) => { - if (!userId || !organization?.id || !projectId) return; + if (!userId || !organization?.id || !projectId || !viewerRole?.id) return; setProjectMemberRole( create(SetProjectMemberRoleRequestSchema, { projectId: projectId, principalId: userId, principalType: PERMISSIONS.UserNamespace, - roleId: PERMISSIONS.RoleProjectViewer + roleId: viewerRole.id }) ); }, - [setProjectMemberRole, organization?.id, projectId] + [setProjectMemberRole, organization?.id, projectId, viewerRole] ); const addTeam = useCallback( (teamId: string) => { - if (!teamId || !organization?.id || !projectId) return; + if (!teamId || !organization?.id || !projectId || !viewerRole?.id) return; setProjectMemberRole( create(SetProjectMemberRoleRequestSchema, { projectId: projectId, principalId: teamId, principalType: PERMISSIONS.GroupNamespace, - roleId: PERMISSIONS.RoleProjectViewer + roleId: viewerRole.id }) ); }, - [setProjectMemberRole, organization?.id, projectId] + [setProjectMemberRole, organization?.id, projectId, viewerRole] ); return ( From 3b244d5055b2d9949f6d0ea569459a02cb84fb8c Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Fri, 3 Apr 2026 12:24:34 +0530 Subject: [PATCH 03/16] fix: pass roles prop to AddMemberDropdown component AddMemberDropdown is a separate child component that needs the roles array to resolve the viewer role UUID. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/sdk/react/views/projects/details/project-members.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/sdk/react/views/projects/details/project-members.tsx b/web/sdk/react/views/projects/details/project-members.tsx index 31cee7669..828f7871f 100644 --- a/web/sdk/react/views/projects/details/project-members.tsx +++ b/web/sdk/react/views/projects/details/project-members.tsx @@ -145,6 +145,7 @@ export const ProjectMembers = ({ canUpdateProject={canUpdateProject} refetch={refetch} members={members} + roles={roles} /> )} @@ -166,6 +167,7 @@ interface AddMemberDropdownProps { projectId: string; canUpdateProject: boolean; members?: User[]; + roles?: Role[]; refetch?: () => void; } @@ -173,6 +175,7 @@ const AddMemberDropdown = ({ projectId, canUpdateProject, members, + roles = [], refetch }: AddMemberDropdownProps) => { const [query, setQuery] = useState(''); From 2243fb18a4ac098d1ae038140546f93475a0b6f7 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Tue, 7 Apr 2026 11:36:40 +0530 Subject: [PATCH 04/16] feat: migrate views-new project files to use SetProjectMemberRole and RemoveProjectMember Update the design-revamped SDK views to use the new atomic RPCs: - add-member-menu.tsx: use SetProjectMemberRole with fetched role UUID - remove-member-dialog.tsx: use RemoveProjectMember - project-details-view.tsx: use SetProjectMemberRole for role change Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mocks/membership_service.go | 86 +++++++++++++++++++ .../projects/components/add-member-menu.tsx | 50 +++++++---- .../components/remove-member-dialog.tsx | 29 ++----- .../projects/project-details-view.tsx | 55 +++--------- 4 files changed, 139 insertions(+), 81 deletions(-) create mode 100644 internal/api/v1beta1connect/mocks/membership_service.go diff --git a/internal/api/v1beta1connect/mocks/membership_service.go b/internal/api/v1beta1connect/mocks/membership_service.go new file mode 100644 index 000000000..7a81278b0 --- /dev/null +++ b/internal/api/v1beta1connect/mocks/membership_service.go @@ -0,0 +1,86 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MembershipService is an autogenerated mock type for the MembershipService type +type MembershipService struct { + mock.Mock +} + +type MembershipService_Expecter struct { + mock *mock.Mock +} + +func (_m *MembershipService) EXPECT() *MembershipService_Expecter { + return &MembershipService_Expecter{mock: &_m.Mock} +} + +// AddOrganizationMember provides a mock function with given fields: ctx, orgID, principalID, principalType, roleID +func (_m *MembershipService) AddOrganizationMember(ctx context.Context, orgID string, principalID string, principalType string, roleID string) error { + ret := _m.Called(ctx, orgID, principalID, principalType, roleID) + + if len(ret) == 0 { + panic("no return value specified for AddOrganizationMember") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) error); ok { + r0 = rf(ctx, orgID, principalID, principalType, roleID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MembershipService_AddOrganizationMember_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddOrganizationMember' +type MembershipService_AddOrganizationMember_Call struct { + *mock.Call +} + +// AddOrganizationMember is a helper method to define mock.On call +// - ctx context.Context +// - orgID string +// - principalID string +// - principalType string +// - roleID string +func (_e *MembershipService_Expecter) AddOrganizationMember(ctx interface{}, orgID interface{}, principalID interface{}, principalType interface{}, roleID interface{}) *MembershipService_AddOrganizationMember_Call { + return &MembershipService_AddOrganizationMember_Call{Call: _e.mock.On("AddOrganizationMember", ctx, orgID, principalID, principalType, roleID)} +} + +func (_c *MembershipService_AddOrganizationMember_Call) Run(run func(ctx context.Context, orgID string, principalID string, principalType string, roleID string)) *MembershipService_AddOrganizationMember_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string)) + }) + return _c +} + +func (_c *MembershipService_AddOrganizationMember_Call) Return(_a0 error) *MembershipService_AddOrganizationMember_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MembershipService_AddOrganizationMember_Call) RunAndReturn(run func(context.Context, string, string, string, string) error) *MembershipService_AddOrganizationMember_Call { + _c.Call.Return(run) + return _c +} + +// NewMembershipService creates a new instance of MembershipService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMembershipService(t interface { + mock.TestingT + Cleanup(func()) +}) *MembershipService { + mock := &MembershipService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/web/sdk/react/views-new/projects/components/add-member-menu.tsx b/web/sdk/react/views-new/projects/components/add-member-menu.tsx index 5558361d1..7ebd37918 100644 --- a/web/sdk/react/views-new/projects/components/add-member-menu.tsx +++ b/web/sdk/react/views-new/projects/components/add-member-menu.tsx @@ -16,8 +16,10 @@ import { useQuery, useMutation } from '@connectrpc/connect-query'; import { FrontierServiceQueries, ListOrganizationUsersRequestSchema, - CreatePolicyForProjectRequestSchema, - type User + ListRolesRequestSchema, + SetProjectMemberRoleRequestSchema, + type User, + type Role } from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; import { useFrontier } from '../../../contexts/FrontierContext'; @@ -80,8 +82,22 @@ export function AddMemberMenu({ [orgUsers, members] ); - const { mutate: createPolicyForProject } = useMutation( - FrontierServiceQueries.createPolicyForProject, + const { data: rolesData } = useQuery( + FrontierServiceQueries.listRoles, + create(ListRolesRequestSchema, { + state: 'enabled', + scopes: [PERMISSIONS.ProjectNamespace] + }), + { enabled: !!projectId } + ); + + const viewerRole = useMemo( + () => (rolesData?.roles as Role[] ?? []).find((r: Role) => r.name === PERMISSIONS.RoleProjectViewer), + [rolesData] + ); + + const { mutate: setProjectMemberRole } = useMutation( + FrontierServiceQueries.setProjectMemberRole, { onSuccess: () => { toastManager.add({ @@ -102,30 +118,32 @@ export function AddMemberMenu({ const addMember = useCallback( (userId: string) => { - if (!userId || !organization?.id || !projectId) return; - const principal = `${PERMISSIONS.UserNamespace}:${userId}`; - createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { + if (!userId || !organization?.id || !projectId || !viewerRole?.id) return; + setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { projectId, - body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + principalId: userId, + principalType: PERMISSIONS.UserNamespace, + roleId: viewerRole.id }) ); }, - [createPolicyForProject, organization?.id, projectId] + [setProjectMemberRole, organization?.id, projectId, viewerRole] ); const addTeam = useCallback( (teamId: string) => { - if (!teamId || !organization?.id || !projectId) return; - const principal = `${PERMISSIONS.GroupNamespace}:${teamId}`; - createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { + if (!teamId || !organization?.id || !projectId || !viewerRole?.id) return; + setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { projectId, - body: { roleId: PERMISSIONS.RoleProjectViewer, principal } + principalId: teamId, + principalType: PERMISSIONS.GroupNamespace, + roleId: viewerRole.id }) ); }, - [createPolicyForProject, organization?.id, projectId] + [setProjectMemberRole, organization?.id, projectId, viewerRole] ); const toggleShowTeam = useCallback(() => { diff --git a/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx b/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx index 6a93938f9..9c3ed2482 100644 --- a/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx +++ b/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx @@ -11,11 +11,9 @@ import { toastManager } from '@raystack/apsara-v1'; import { useMutation } from '@connectrpc/connect-query'; import { FrontierServiceQueries, - ListPoliciesRequestSchema, - DeletePolicyRequestSchema + RemoveProjectMemberRequestSchema } from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; -import { useQuery } from '@connectrpc/connect-query'; import { handleConnectError } from '~/utils/error'; export interface RemoveMemberPayload { @@ -69,28 +67,19 @@ function RemoveMemberForm({ }: RemoveMemberFormProps) { const [isLoading, setIsLoading] = useState(false); - const { data: policiesData } = useQuery( - FrontierServiceQueries.listPolicies, - create(ListPoliciesRequestSchema, { - projectId: payload.projectId, - userId: payload.memberId - }), - { enabled: !!payload.projectId && !!payload.memberId } - ); - - const policies = policiesData?.policies ?? []; - - const { mutateAsync: deletePolicy } = useMutation( - FrontierServiceQueries.deletePolicy + const { mutateAsync: removeProjectMember } = useMutation( + FrontierServiceQueries.removeProjectMember ); async function handleRemove() { setIsLoading(true); try { - await Promise.all( - policies.map(p => - deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) - ) + await removeProjectMember( + create(RemoveProjectMemberRequestSchema, { + projectId: payload.projectId, + principalId: payload.memberId, + principalType: 'app/user' + }) ); toastManager.add({ title: 'Member removed', type: 'success' }); refetch(); diff --git a/web/sdk/react/views-new/projects/project-details-view.tsx b/web/sdk/react/views-new/projects/project-details-view.tsx index 1095157a5..ba9e4d31d 100644 --- a/web/sdk/react/views-new/projects/project-details-view.tsx +++ b/web/sdk/react/views-new/projects/project-details-view.tsx @@ -24,20 +24,15 @@ import deleteIcon from '../../assets/delete.svg'; import { toastManager } from '@raystack/apsara-v1'; import { useQuery, - useMutation, - createConnectQueryKey, - useTransport + useMutation } from '@connectrpc/connect-query'; -import { useQueryClient } from '@tanstack/react-query'; import { FrontierServiceQueries, ListProjectGroupsRequestSchema, ListProjectUsersRequestSchema, GetProjectRequestSchema, ListRolesRequestSchema, - CreatePolicyForProjectRequestSchema, - DeletePolicyRequestSchema, - ListPoliciesRequestSchema, + SetProjectMemberRoleRequestSchema, type Role as ProtoRole } from '@raystack/proton/frontier'; import { create } from '@bufbuild/protobuf'; @@ -290,49 +285,19 @@ export function ProjectDetailsView({ ] ); - const queryClient = useQueryClient(); - const transport = useTransport(); - - const { mutateAsync: deletePolicy } = useMutation( - FrontierServiceQueries.deletePolicy - ); - const { mutateAsync: createPolicyForProject } = useMutation( - FrontierServiceQueries.createPolicyForProject + const { mutateAsync: setProjectMemberRole } = useMutation( + FrontierServiceQueries.setProjectMemberRole ); const updateMemberRole = useCallback( async (memberId: string, isTeam: boolean, role: ProtoRole) => { try { - const principal = isTeam - ? `${PERMISSIONS.GroupNamespace}:${memberId}` - : `${PERMISSIONS.UserNamespace}:${memberId}`; - - const input = create(ListPoliciesRequestSchema, { - projectId, - ...(isTeam ? { groupId: memberId } : { userId: memberId }) - }); - - const policiesData = await queryClient.fetchQuery({ - queryKey: createConnectQueryKey({ - schema: FrontierServiceQueries.listPolicies, - transport, - input, - cardinality: 'finite' - }) - }); - - const policies = (policiesData as { policies?: { id?: string }[] })?.policies ?? []; - - await Promise.all( - policies.map(p => - deletePolicy(create(DeletePolicyRequestSchema, { id: p.id || '' })) - ) - ); - - await createPolicyForProject( - create(CreatePolicyForProjectRequestSchema, { + await setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { projectId, - body: { roleId: role.id as string, principal } + principalId: memberId, + principalType: isTeam ? PERMISSIONS.GroupNamespace : PERMISSIONS.UserNamespace, + roleId: role.id as string }) ); refetchMembers(); @@ -348,7 +313,7 @@ export function ProjectDetailsView({ }); } }, - [queryClient, transport, deletePolicy, createPolicyForProject, projectId, refetchMembers] + [setProjectMemberRole, projectId, refetchMembers] ); const handleDeleteSuccess = useCallback(() => { From 3d02d4ed35cb8cbdfcdd7c52527545976c419749 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Wed, 8 Apr 2026 11:38:18 +0530 Subject: [PATCH 05/16] fix: address review comments on SDK migration - Fix hardcoded principalType in views-new remove-member-dialog: pass memberType through payload to support group removal - Add ownerRoleId to dependency arrays in manage-service-user-projects and add-service-account dialogs to prevent stale closures - Move ownerRoleId validation before service user creation to avoid orphaned service users Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/membership-package-tasks.md | 762 ++++++++++++++++++ .../components/remove-member-dialog.tsx | 3 +- .../manage-service-user-projects-dialog.tsx | 2 +- .../list/add-service-account-dialog.tsx | 7 +- 4 files changed, 770 insertions(+), 4 deletions(-) create mode 100644 docs/membership-package-tasks.md diff --git a/docs/membership-package-tasks.md b/docs/membership-package-tasks.md new file mode 100644 index 000000000..83d703bbc --- /dev/null +++ b/docs/membership-package-tasks.md @@ -0,0 +1,762 @@ +# Membership Package — Task Breakdown + +Parent issue: [#1478](https://github.com/raystack/frontier/issues/1478) +Prior work: [#1459](https://github.com/raystack/frontier/issues/1459) (SetOrganizationMemberRole), [#1461](https://github.com/raystack/frontier/issues/1461) (SetProjectMemberRole + RemoveProjectMember) + +--- + +## Executive Summary + +### What we are doing + +Introducing a `core/membership` package that becomes the **single owner** of all member access management across organizations, projects, and groups. Today, membership logic (adding members, changing roles, removing members, listing members) is scattered across 5 packages (`organization`, `project`, `group`, `user`, `serviceuser`) with each independently calling `policyService` and `relationService`. The membership package centralizes this into one place with atomic guarantees. + +### Why + +Frontier manages access through two mechanisms: **policies** (role bindings that create 3 SpiceDB relations internally) and **direct relations** (explicit SpiceDB relations like `org#owner@user`). Today, the code that manages these is scattered across `organization`, `project`, `group`, `user`, and `serviceuser` packages — each with its own way of creating policies, relations, or both. Some create both, some create only one, some forget to clean up on role changes. This scattered surface area is the root cause of leaky relations: there are too many code paths that can create or modify access, and no single place that enforces consistency. The most visible bug: demoting an org owner to viewer replaces the policy but the code path never touches the `org#owner@user` relation, so the user retains owner access. + +### What we achieve + +- **Bug fix:** Every mutation (add/set/remove) updates both policy and relation through a single code path — no more stale permissions from scattered call sites +- **Single source of truth:** All reads (list members, list resources for a user) go through policies, not SpiceDB relations that can be stale +- **Simpler codebase:** Organization, project, group, user, and serviceuser packages become pure CRUD (create, read, update, delete entities). All access management logic moves into a single `membership` package. One package for entities, one package for access — clear separation. +- **Code consolidation:** ~30 service functions across 5 packages collapse into 10 public membership functions + shared private core +- **Consistent API:** New RPCs for org and group follow the same `(resource_id, principal_id, principal_type, role_id)` pattern that project already has +- **All principal types:** Org and group RPCs gain support for serviceusers and groups as principals (today they only support users) +- **Explicit roles:** `AddOrganizationMember` and `SetGroupMemberRole` require an explicit role (today `AddOrganizationUsers` hardcodes viewer, `AddGroupUsers` hardcodes member) +- **Better audit logging:** All access mutations flow through one package, giving fine-grained control over what gets logged. Today audit records are inconsistently created — some code paths log, some don't. With membership owning all access changes, every add/set/remove can produce a consistent audit trail with uniform event types and metadata + +### High-level approach + +``` +Before: + handler → orgService.AddMember() → policyService.Create() + relationService.Create() [not atomic] + handler → orgService.SetMemberRole() → policyService.Delete/Create() [no relation update] + handler → orgService.RemoveUsers() → policyService.Delete() + relationService.Delete() [not atomic] + +After: + handler → membershipService.AddOrganizationMember() → replacePolicy() + replaceRelation() [atomic] + handler → membershipService.SetOrganizationMemberRole() → replacePolicy() + replaceRelation() [atomic] + handler → membershipService.RemoveOrganizationMember() → removeAllPolicies() + removeRelations() [atomic] +``` + +The membership package has **public functions per resource type** (org/project/group) for type-safe, validation-specific entry points, and **private shared core** (`replacePolicy`, `replaceRelation`, `removeAllPolicies`, `removeRelations`) that handles the atomic policy+relation operations. + +### What gets added + +| Addition | Count | +|---|---| +| New package: `core/membership/` | 1 | +| New public functions (SetRole, Remove, List for org/project/group) | ~10 | +| New proto RPCs (`AddOrganizationMember`, `RemoveOrganizationMember`, `SetGroupMemberRole`, `RemoveGroupMember`) | 4 | + +### What gets deleted + +| Deletion | Count | +|---|---| +| Service functions removed from `core/organization` | 9 (`AddMember`, `AddUsers`, `SetMemberRole`, `RemoveUsers`, `ListByUser`, + 4 helpers) | +| Service functions removed from `core/project` | 9 (`SetMemberRole`, `RemoveMember`, `ListByUser`, `ListUsers`, `ListServiceUsers`, `ListGroups`, + 3 helpers) | +| Service functions removed from `core/group` | 7 (`AddMember`, `addOwner`, `AddUsers`, `addMemberPolicy`, `RemoveUsers`, `removeUsers`, `ListByUser`) | +| Service functions removed from `core/user` | 3 (`ListByOrg`, `ListByGroup`, `getUserIDsFromPolicies`) | +| Cascade logic removed from `core/deleter` | 1 (`RemoveUsersFromOrg`) | +| Old proto RPCs deprecated then deleted | 4 (`AddOrganizationUsers`, `RemoveOrganizationUser`, `AddGroupUsers`, `RemoveGroupUser`) | +| **Total functions removed** | **~29** | + +### Task overview + +| # | Task | New proto? | Priority | +|---|---|---|---| +| 1 | `AddOrganizationMember` — create package + org add/set + serviceuser migration | Yes | High | +| 2 | `RemoveOrganizationMember` — org remove + cascade | Yes | High | +| 3 | Project member mutations — rewire existing RPCs | No | High | +| 4 | `SetGroupMemberRole` — group add/set | Yes | Last | +| 5 | `RemoveGroupMember` — group remove | Yes | Last | +| 6 | `ListPrincipalsByResource` — list members of a resource | No | Medium | +| 7 | `ListResourcesByPrincipal` — list resources for a user | No | Medium | + +### What changes for the SDK + +- **No more policy/relation RPCs for member management:** The SDK stops using `CreatePolicy`, `DeletePolicy`, `ListPolicies`, `CreateRelation`, `DeleteRelation` for adding/removing/changing members. These were low-level building blocks that leaked internal implementation details (policy IDs, relation triples, principal string formatting) to the client. The SDK now works entirely with role-based RPCs: `AddOrganizationMember`, `SetOrganizationMemberRole`, `RemoveOrganizationMember`, `SetProjectMemberRole`, `RemoveProjectMember`, and the new group equivalents. +- **Explicit roles everywhere:** `AddOrganizationMember` requires a `role_id` — no more hidden defaults. The SDK fetches available roles via `ListRoles(scope)` and passes the chosen role UUID. Same pattern already working for project (migrated in #1461). +- **All principal types from one RPC:** The SDK can add users, service users, and groups to any resource using the same RPC with `principal_type`. Today org and group RPCs only accept `user_ids[]`. +- **Simpler error handling:** One RPC call instead of multi-step `list → delete → create` sequences. No more `Promise.allSettled` workarounds in the React SDK for partial failures during role changes. + +### What changes for developer experience + +- **One package to understand:** A new developer working on member management only needs to read `core/membership/`. Today they'd have to trace through `organization`, `project`, `group`, `user`, `serviceuser`, `deleter`, and handler-level policy calls to understand the full picture. +- **One place to debug:** If a user has unexpected access, you check their policies via the membership package. No need to cross-reference direct SpiceDB relations against policies to find which path is granting access. +- **Hard to get wrong:** Adding member management to a new feature (e.g., supporting service accounts in groups, or adding role-based access to billing accounts) means implementing a few public functions in the membership package following the established pattern. No risk of forgetting the relation, the audit log, or the min-owner check — the pattern is right there. +- **Entity services stay simple:** `core/organization/`, `core/project/`, `core/group/` become straightforward CRUD services. No interleaved access management logic to reason about when modifying entity lifecycle code. + +### Vendor portability (SpiceDB → OpenFGA or similar) + +Today, SpiceDB specifics are spread across every service that calls `relationService.Create`, `relationService.LookupResources`, `policyService.Create` (which internally creates SpiceDB relations). Swapping SpiceDB for another authorization engine (OpenFGA, Ory Keto, custom) would require touching every call site — org, project, group, user, serviceuser, deleter services plus 6+ handlers. + +After this work: +- **Mutations:** All SpiceDB writes for membership go through the membership package's private core (`replacePolicy`, `replaceRelation`). Changing the authorization backend means changing these ~4 private functions. Zero changes to org/project/group services or handlers. +- **Reads:** All membership reads go through `ListPrincipalsByResource` and `ListResourcesByPrincipal`. Swapping the query backend is localized to these 2 functions. +- **Authz checks:** `CheckPermission`/`BatchCheckPermission` are already behind the `relation.AuthzRepository` interface — unrelated to this work, but also a single-point swap. +- **Net effect:** Membership package becomes a clean abstraction boundary. The rest of the codebase talks in terms of "add member with role" / "remove member" / "list members" — it doesn't know or care whether the backing store is SpiceDB, OpenFGA, or a Postgres table. + +### What does NOT change + +- **Authorization checks:** `CheckPermission` and `BatchCheckPermission` continue to work exactly as they do today — they read from SpiceDB's permission graph, which is still populated by policies (via role bindings) and any remaining direct relations. No change to how the SDK or handlers verify access before an action. +- **SpiceDB schema:** No schema changes in this phase. The `owner`, `member`, `granted`, `role`, `bearer` relations all stay as-is. Schema cleanup (removing direct relations) may happen later depending on how much benefit we see — it's not committed. +- **Role definitions:** `core/role/` service stays independent — creating roles, managing permissions on roles, role-permission relations in SpiceDB are untouched. +- **Hierarchy relations:** `project→org`, `group→org`, `serviceuser→org` (identity link), `invitation→org` — these are entity links, not membership. They stay as direct relation calls in their respective services. +- **Platform relations:** `platform→admin`, `platform→member` for superuser access — stays in user service. +- **Billing, preferences, invitations:** Completely unrelated, no changes. +- **Existing List RPCs:** `ListOrganizationUsers`, `ListProjectUsers`, `ListGroupUsers` etc. — same proto, same response shape. The handlers rewire to call the membership package instead of calling policy/user/project services directly (Tasks 6 and 7). + +### Phase 2 (future, if needed) + +Once all call sites go through the membership package, removing direct SpiceDB relations becomes a one-file change inside the package — delete the `replaceRelation`/`removeRelations` calls, update SpiceDB schema, run migration to clean stale relations. No external callers change. Whether we do this depends on the benefits observed after Phase 1 stabilizes. + +--- + +## Part 1: Mutations + +### Task 1: `AddOrganizationMember` — create membership package + proto + impl + migrate + +**Context:** This is the first task — it creates `core/membership/` from scratch. The private shared core (`replacePolicy`, `replaceRelation`, etc.) gets extracted here as it emerges from implementing the first public function. The existing `AddOrganizationUsers` RPC hides the role (hardcodes viewer) and only supports users. The new RPC makes role explicit and supports all principal types. + +**Proto (raystack/proton):** + +Add new RPC to `raystack/frontier/v1beta1/frontier.proto`: + +```protobuf +rpc AddOrganizationMember(AddOrganizationMemberRequest) returns (AddOrganizationMemberResponse); + +message AddOrganizationMemberRequest { + string org_id = 1; + string principal_id = 2; + string principal_type = 3; // app/user, app/serviceuser + string role_id = 4; +} +message AddOrganizationMemberResponse {} +``` + +Do NOT modify or delete `AddOrganizationUsers` — it stays for now. + +**Package creation (`core/membership/`):** + +- `service.go` — Service struct with dependency interfaces: + - `PolicyService` (Create, List, Delete) + - `RelationService` (Create, Delete) + - `RoleService` (Get) + - `OrgRepository` (GetByID — for validation) + - `UserService` (GetByID — for audit) + - `AuditRecordRepository` (Create) +- Private shared core (extracted during implementation, not designed upfront): + - `replacePolicy(ctx, resourceID, resourceType, principalID, principalType, roleID)` — delete existing policies for principal+resource, create new one + - `replaceRelation(ctx, resourceID, resourceType, principalID, principalType, relationName)` — delete existing relations, create new one matching role + - Helper to map roleID to relation name (e.g. `app_organization_owner` → `owner`, `app_organization_viewer` → `member`) +- Wire into bootstrap/DI (`internal/bootstrap/`) + +**Public functions to implement:** + +`AddOrganizationMember(ctx, orgID, principalID, principalType, roleID) error`: +- Validate role is org-scoped (one of `app_organization_viewer`, `app_organization_manager`, `app_organization_owner`, or custom org role) +- List existing policies for principal+org → if any exist → `ErrAlreadyMember` +- `replacePolicy()` — create policy +- `replaceRelation()` — create matching relation (e.g. `org#member@user` for viewer/manager, `org#owner@user` for owner) +- Create audit record (org member added event) + +`SetOrganizationMemberRole(ctx, orgID, principalID, principalType, roleID) error`: +- This is the existing `org.SetMemberRole` logic moved here with enhancements +- Validate role is org-scoped +- List existing policies for principal+org → if none → `ErrNotMember` (must already be a member) +- Validate min owner constraint — if principal is last owner and new role is not owner → `ErrLastOwner` +- `replacePolicy()` — delete old policies + create new one +- `replaceRelation()` — delete old relation + create new one matching role ← **THIS FIXES THE LEAK BUG** (today `org.SetMemberRole` never touches relations) +- Create audit record + +**Difference between Add and Set:** +- `Add` → rejects if already a member (`ErrAlreadyMember`) +- `Set` → rejects if NOT a member (`ErrNotMember`), validates min owner constraint + +**Call chain — AddOrganizationMember (new RPC):** + +``` +Before (AddOrganizationUsers): + handler → orgService.AddUsers(orgID, userIDs[]) + → loop: orgService.AddMember(orgID, "member", principal) + → policyService.Create(role=viewer) // hardcoded default + → relationService.Create(org#member@user) // separate, not atomic + → audit + +After (AddOrganizationMember): + handler → membershipService.AddOrganizationMember(orgID, principalID, principalType, roleID) + → validateOrgRole(roleID) + → listExistingPolicies(orgID, principalID, principalType) + → if len > 0 → ErrAlreadyMember + → s.replacePolicy(orgID, "app/organization", principalID, principalType, roleID) + → s.replaceRelation(orgID, "app/organization", principalID, principalType, derivedRelation) + → audit +``` + +**Call chain — SetOrganizationMemberRole (existing RPC, rewire):** + +``` +Before: + handler → orgService.SetMemberRole(orgID, userID, roleID) + → validateSetMemberRoleRequest() + → getUserOrgPolicies() + → if len == 0 → ErrNotMember + → validateMinOwnerConstraint() + → replaceUserOrgPolicies() // policy only, NO relation update ← BUG + +After: + handler → membershipService.SetOrganizationMemberRole(orgID, userID, "app/user", roleID) + → validateOrgRole(roleID) + → listExistingPolicies(orgID, principalID, principalType) + → if len == 0 → ErrNotMember + → validateMinOwnerConstraint() + → s.replacePolicy(...) // delete old + create new + → s.replaceRelation(...) // delete old + create new ← FIXES BUG + → audit +``` + +**ServiceUser migration (also in this task):** + +``` +Before (serviceuser.Create): + → repo.Create(su) + → relationService.Create(org#member@su) // no policy! + → relationService.Create(su#org@org) // identity link + → relationService.Create(su#user@creator) // identity link + +After: + → repo.Create(su) + → membershipService.AddOrganizationMember(orgID, suID, "app/serviceuser", defaultOrgRole) + → relationService.Create(su#org@org) // identity link stays + → relationService.Create(su#user@creator) // identity link stays +``` + +ServiceUser service keeps `relationService` for identity links only. + +**Handler wiring:** +- New `AddOrganizationMember` RPC handler → calls `membershipService.AddOrganizationMember` +- Existing `SetOrganizationMemberRole` RPC handler (from #1459) → rewire from `orgService.SetMemberRole` to `membershipService.SetOrganizationMemberRole` +- Existing `AddOrganizationUsers` RPC handler → rewire from `orgService.AddUsers` to loop `membershipService.AddOrganizationMember` with default viewer role (backward compat until deleted) + +**Delete from `core/organization/service.go`:** +- `AddMember()` (line ~207) — replaced by `membership.AddOrganizationMember` +- `AddUsers()` (line ~348) — replaced by handler looping `membership.AddOrganizationMember` +- `SetMemberRole()` (line ~361) — replaced by `membership.SetOrganizationMemberRole` +- `validateSetMemberRoleRequest()` — moves into membership +- `getUserOrgPolicies()` — moves into membership +- `validateMinOwnerConstraint()` — moves into membership +- `replaceUserOrgPolicies()` — absorbed by `replacePolicy` private core +- Remove `PolicyService` and `RelationService` interfaces from org service if no other org functions use them (check `Create` org — it creates platform relation + owner policy; these may need to stay or also move) + +**Explicit relations that must be managed in every org mutation:** + +After upserting/deleting the policy, each mutation must also handle these explicit direct relations. If not cleaned up, the old relation continues granting the old permissions — this is the core bug today (e.g., org creator demoted from owner to viewer still retains owner access via stale `org#owner@user` relation). + +| Role | Explicit relation to create | On role change / add, must first delete | +|---|---|---| +| `app_organization_owner` | `org#owner@principal` | Any existing `org#owner` AND `org#member` for this principal | +| `app_organization_manager` | `org#member@principal` | Any existing `org#owner` AND `org#member` for this principal | +| `app_organization_viewer` | `org#member@principal` | Any existing `org#owner` AND `org#member` for this principal | + +On remove: delete ALL explicit relations (`org#owner` and `org#member`) for the principal. + +ServiceUser special case: `serviceuser.Create()` today creates `org#member@serviceuser` with NO policy. After migration, `AddOrganizationMember` creates both the policy and the relation. + +**Schema constants to be aware of:** +- `schema.RoleOrganizationOwner` = `app_organization_owner` +- `schema.RoleOrganizationManager` = `app_organization_manager` +- `schema.RoleOrganizationViewer` = `app_organization_viewer` +- `schema.OwnerRelationName` = `owner` +- `schema.MemberRelationName` = `member` +- `schema.OrganizationNamespace` = `app/organization` +- Org constants: `AdminRole = schema.RoleOrganizationOwner`, `MemberRole = schema.RoleOrganizationViewer` + +**Tests:** +- `AddOrganizationMember`: happy path, already-member error, invalid role error +- `SetOrganizationMemberRole`: happy path, not-member error, last-owner error, relation cleanup +- Verify the bug fix: after `SetOrganizationMemberRole` from owner→viewer, the old `org#owner@user` relation must be gone +- ServiceUser: after creation, serviceuser appears in policy-based listing (has policy now) + +--- + +### Task 2: `RemoveOrganizationMember` — proto + impl + migrate + +**Context:** Current `RemoveOrganizationUser` RPC goes through `deleterService.RemoveUsersFromOrg` which does a cascade: removes user from all org projects, all org groups, then removes org-level policies + org relation. This cascade logic either moves into membership or deleter calls membership at each level. The new RPC supports all principal types, not just users. + +**Proto (raystack/proton):** + +```protobuf +rpc RemoveOrganizationMember(RemoveOrganizationMemberRequest) returns (RemoveOrganizationMemberResponse); + +message RemoveOrganizationMemberRequest { + string org_id = 1; + string principal_id = 2; + string principal_type = 3; +} +message RemoveOrganizationMemberResponse {} +``` + +Do NOT modify or delete `RemoveOrganizationUser` — it stays for now. + +**Public function — `RemoveOrganizationMember(ctx, orgID, principalID, principalType) error`:** +- List existing policies for principal+org → if none → `ErrNotMember` +- Validate min owner constraint — can't remove the last owner +- **Cascade:** list org's projects → call `s.removeAllPolicies` for each project where principal has policies +- **Cascade:** list org's groups → call `s.removeAllPolicies` + `s.removeRelations` for each group where principal has policies +- `removeAllPolicies(orgID, "app/organization", principalID, principalType)` — delete org-level policies +- `removeRelations(orgID, "app/organization", principalID, principalType)` — delete org-level relations +- Create audit record (org member removed event) + +**Private shared core (new in this task):** +- `removeAllPolicies(ctx, resourceID, resourceType, principalID, principalType)` — find and delete all policies for principal+resource +- `removeRelations(ctx, resourceID, resourceType, principalID, principalType)` — delete all relations for principal+resource + +**Decision to make during implementation — cascade approach:** +- Option A: `membership.RemoveOrganizationMember` does cascade internally (calls its own `RemoveProjectMember`/`RemoveGroupMember`) +- Option B: keep cascade in deleter, deleter calls membership at each level +- Option A is cleaner if Tasks 3-5 are done. Option B works regardless of task order. Pick based on what's implemented when you get here. + +**Call chain:** + +``` +Before (RemoveOrganizationUser): + handler → deleterService.RemoveUsersFromOrg(orgID, [userID]) + → list ALL user policies across all resources + → for each policy: + project-level? delete policy if belongs to org's projects + group-level? groupService.RemoveUsers() if belongs to org's groups + resource-level? delete policy if belongs to org's project resources + → orgService.RemoveUsers(orgID, [userID]) + → policyService.List(orgID, userID) + Delete per policy + → relationService.Delete(org#*@user) + → audit + +After (RemoveOrganizationMember): + handler → membershipService.RemoveOrganizationMember(orgID, principalID, principalType) + → validate min owner constraint + → cascade: for each org project → s.removeAllPolicies(projectID, ...) + → cascade: for each org group → s.removeAllPolicies + s.removeRelations(groupID, ...) + → s.removeAllPolicies(orgID, "app/organization", principalID, principalType) + → s.removeRelations(orgID, "app/organization", principalID, principalType) + → audit +``` + +**Handler wiring:** +- New `RemoveOrganizationMember` RPC handler → calls `membershipService.RemoveOrganizationMember` +- Existing `RemoveOrganizationUser` RPC handler → rewire from `deleterService.RemoveUsersFromOrg` to `membershipService.RemoveOrganizationMember` (backward compat until deleted) + +**Delete from `core/organization/service.go`:** +- `RemoveUsers()` (line ~508) — replaced by membership + +**Delete/refactor from `core/deleter/service.go`:** +- `RemoveUsersFromOrg()` (line ~254) — cascade logic absorbed by membership. The `DeleteUser()` method in deleter calls `RemoveUsersFromOrg` — update it to call membership instead. + +**Explicit relations to clean up on remove:** + +Removing an org member must delete ALL explicit relations for that principal across the cascade: +- Org level: delete `org#owner@principal` and `org#member@principal` +- For each org project: no explicit relations (project is clean) — only policies +- For each org group: delete `group#owner@principal` and `group#member@principal` + +If these are not cleaned up, the principal retains access via stale relations even after all policies are deleted. + +**Edge cases to handle:** +- Last owner removal → reject +- Principal has policies at org + project + group level → all must be cleaned +- Resource-level policies under org projects (see deleter line ~308) → also clean up +- Partial failure handling: if removing from one project fails, should we abort or continue? Currently deleter uses `errors.Join` and continues. + +**Tests:** +- Happy path, last-owner error, not-member error +- Cascade: removing user from org also removes their project and group memberships within that org +- Verify both policies AND relations are cleaned up at every level + +--- + +### Task 3: Project member mutations — impl + migrate (no new proto, RPCs from #1461) + +**Context:** Project RPCs (`SetProjectMemberRole`, `RemoveProjectMember`) already exist from #1461 and are already clean — policy only, no direct relations. This task moves the logic from `core/project/service.go` into `core/membership/` for consistency. The private shared core from Task 1 gets reused here. The key project-specific logic is: validate org membership of principal before allowing project access. + +**Public function — `SetProjectMemberRole(ctx, projectID, principalID, principalType, roleID) error`:** +- This is the existing `project.SetMemberRole` moved here +- Validate role is project-scoped (`app_project_owner`, `app_project_manager`, `app_project_viewer`, or custom) +- Resolve project → get parent org ID (needs `ProjectRepository.GetByID` as dependency) +- Validate org membership: principal must have at least one policy on the parent org. If not → `ErrNotOrgMember` (this is `validatePrincipal` logic from project service today) +- Validate principal_type is one of `app/user`, `app/serviceuser`, `app/group` +- `replacePolicy()` — reuse private core from Task 1 +- No relation ops — project stays clean +- Note: this is an **upsert** — works for both adding a new member and changing an existing member's role (unlike org which has separate Add and Set) + +**Public function — `RemoveProjectMember(ctx, projectID, principalID, principalType) error`:** +- This is the existing `project.RemoveMember` moved here +- Validate principal_type +- `removeAllPolicies(projectID, "app/project", principalID, principalType)` — reuse private core +- If nothing removed → `ErrNotMember` + +**Call chain — SetProjectMemberRole:** + +``` +Before: + handler → projectService.SetMemberRole(projectID, principalID, principalType, roleID) + → projectService.Get(projectID) // fetch project → get orgID + → validatePrincipal(orgID, principalID, principalType) // must be org member + → validateProjectRole(roleID) // must be project-scoped + → policyService.List(projectID, principalID) → Delete old → Create new + +After: + handler → membershipService.SetProjectMemberRole(projectID, principalID, principalType, roleID) + → validateProjectRole(roleID) + → resolve project → get orgID + → validateOrgMembership(orgID, principalID, principalType) + → s.replacePolicy(projectID, "app/project", principalID, principalType, roleID) + // no relations — project stays clean +``` + +**Call chain — RemoveProjectMember:** + +``` +Before: + handler → projectService.RemoveMember(projectID, principalID, principalType) + → projectService.Get(projectID) + → validate principalType + → policyService.List → if len == 0 → ErrNotMember + → policyService.Delete per policy + +After: + handler → membershipService.RemoveProjectMember(projectID, principalID, principalType) + → validate principalType + → s.removeAllPolicies(projectID, "app/project", principalID, principalType) + → if nothing removed → ErrNotMember +``` + +**Note on org membership validation:** +`SetProjectMemberRole` must verify the principal is an org member. This means membership package needs a way to check "does this principal have any policy on this org?" — which is `listExistingPolicies(orgID, principalID)`. This is the same helper used in Task 1 for `SetOrganizationMemberRole`'s `ErrNotMember` check. + +**Handler wiring:** +- `SetProjectMemberRole` RPC handler → rewire from `projectService.SetMemberRole` to `membershipService.SetProjectMemberRole` +- `RemoveProjectMember` RPC handler → rewire from `projectService.RemoveMember` to `membershipService.RemoveProjectMember` + +**Delete from `core/project/service.go`:** +- `SetMemberRole()` (line ~365) — moved to membership +- `RemoveMember()` (line ~406) — moved to membership +- `validatePrincipal()` — moves into membership as `validateOrgMembership()` +- `validateProjectRole()` — moves into membership +- Remove `RoleService` interface from project service if unused after deletions +- `PolicyService` and `RelationService` may still be needed for listing functions (Part 2) — keep if so + +**Explicit relations: none.** + +Project has no explicit direct relations for membership — only policies. No relation cleanup needed on add, role change, or remove. This is the clean model. + +**Schema constants:** +- `schema.RoleProjectOwner` = `app_project_owner` +- `schema.RoleProjectManager` = `app_project_manager` +- `schema.RoleProjectViewer` = `app_project_viewer` +- `schema.ProjectNamespace` = `app/project` + +**Tests:** +- `SetProjectMemberRole`: happy path (new member), happy path (role change), not-org-member error, invalid role error, invalid principal_type error +- `RemoveProjectMember`: happy path, not-member error + +--- + +### Task 4: `SetGroupMemberRole` — proto + impl + migrate (last priority) + +**Context:** Current `AddGroupUsers` RPC has no role param (hardcodes `group_member`), only supports users, and creates policy + relation non-atomically. The group also has a separate `addOwner` internal method with hardcoded `group_owner` role. The new RPC unifies both with an explicit role and all principal types. + +**Proto (raystack/proton):** + +```protobuf +rpc SetGroupMemberRole(SetGroupMemberRoleRequest) returns (SetGroupMemberRoleResponse); + +message SetGroupMemberRoleRequest { + string group_id = 1; + string org_id = 2; + string principal_id = 3; + string principal_type = 4; + string role_id = 5; +} +message SetGroupMemberRoleResponse {} +``` + +Do NOT modify or delete `AddGroupUsers` — it stays for now. + +**Public function — `SetGroupMemberRole(ctx, groupID, principalID, principalType, roleID) error`:** +- Validate role is group-scoped (`app_group_owner`, `app_group_member`) +- If principal has existing group policies and is being changed from owner → check min owner constraint +- `replacePolicy()` — reuse private core +- `replaceRelation()` — reuse private core. Relation name depends on role: + - `app_group_owner` → `group#owner@principal` + - `app_group_member` → `group#member@principal` +- Note: group `member` relation stays per #1478 (needed for SpiceDB to resolve group-level policies). `owner` relation is being removed in Phase 2 but created in Phase 1 for backward compat. + +**Call chain:** + +``` +Before (AddGroupUsers): + handler → groupService.AddUsers(groupID, userIDs[]) + → loop: groupService.AddMember(groupID, principal) + → addMemberPolicy(): policyService.Create(role=group_member) // hardcoded + → relationService.Create(group#member@user) // separate + +Before (group.addOwner — called during group creation): + → policyService.Create(role=group_owner) + → relationService.Create(group#owner@user) + +After (SetGroupMemberRole): + handler → membershipService.SetGroupMemberRole(groupID, principalID, principalType, roleID) + → validateGroupRole(roleID) + → listExistingPolicies(groupID, principalID, principalType) + → if changing from owner → validateMinOwnerConstraint() + → s.replacePolicy(groupID, "app/group", principalID, principalType, roleID) + → s.replaceRelation(groupID, "app/group", principalID, principalType, derivedRelation) +``` + +**Note on group creation:** When a group is created, the creator is added as owner via `group.addOwner()`. After migration, group creation should call `membershipService.SetGroupMemberRole(groupID, creatorID, "app/user", groupOwnerRole)`. + +**Handler wiring:** +- New `SetGroupMemberRole` RPC handler → calls `membershipService.SetGroupMemberRole` +- Existing `AddGroupUsers` RPC handler → rewire from `groupService.AddUsers` to loop `membershipService.SetGroupMemberRole` with default `group_member` role (backward compat until deleted) +- Group `Create` handler: wherever `addOwner` was called, call membership instead + +**Delete from `core/group/service.go`:** +- `AddMember()` (line ~169) — replaced by membership +- `addOwner()` (line ~194) — replaced by membership +- `AddUsers()` (line ~311) — replaced by handler looping membership +- `addMemberPolicy()` — absorbed into membership +- Remove `PolicyService` and `RelationService` interfaces from group service if no other group functions use them + +**Explicit relations that must be managed in every group mutation:** + +Same pattern as org — after upserting the policy, the matching explicit relation must be created and any old one deleted. If not cleaned up, role changes have no effect. + +| Role | Explicit relation to create | On role change / add, must first delete | +|---|---|---| +| `app_group_owner` | `group#owner@principal` | Any existing `group#owner` AND `group#member` for this principal | +| `app_group_member` | `group#member@principal` | Any existing `group#owner` AND `group#member` for this principal | + +Note: `group#member` relation is one that stays long-term per #1478 — needed for SpiceDB to resolve group-level policies. `group#owner` relation may be removed in Phase 2. + +**Schema constants:** +- `schema.GroupOwnerRole` = `app_group_owner` +- `schema.GroupMemberRole` = `app_group_member` +- `schema.GroupNamespace` = `app/group` + +**Tests:** +- Add member with explicit role +- Change role (member→owner, owner→member) — verify old relation is deleted, new one created +- Min owner constraint +- Group creation still assigns owner through membership + +--- + +### Task 5: `RemoveGroupMember` — proto + impl + migrate (last priority) + +**Context:** Current `RemoveGroupUser` only supports users. The new RPC supports all principal types and ensures atomic cleanup of both policies and relations. + +**Proto (raystack/proton):** + +```protobuf +rpc RemoveGroupMember(RemoveGroupMemberRequest) returns (RemoveGroupMemberResponse); + +message RemoveGroupMemberRequest { + string group_id = 1; + string org_id = 2; + string principal_id = 3; + string principal_type = 4; +} +message RemoveGroupMemberResponse {} +``` + +Do NOT modify or delete `RemoveGroupUser` — it stays for now. + +**Public function — `RemoveGroupMember(ctx, groupID, principalID, principalType) error`:** +- List existing policies → if none → `ErrNotMember` +- Validate min owner constraint (can't remove last owner) +- `removeAllPolicies(groupID, "app/group", principalID, principalType)` — reuse private core +- `removeRelations(groupID, "app/group", principalID, principalType)` — reuse private core +- Audit record + +**Note:** Min owner check currently happens in the handler (`group.go` line ~424) by calling `userService.ListByGroup(groupID, AdminRole)`. After migration, this moves into the membership function. Membership can check this via `listExistingPolicies` filtered by owner role. + +**Call chain:** + +``` +Before (RemoveGroupUser): + handler → check min owner count via userService.ListByGroup(groupID, AdminRole) + handler → groupService.RemoveUsers(groupID, [userID]) + → policyService.List(groupID, userID) + Delete per policy + → relationService.Delete(group#*@user) + → audit + +After (RemoveGroupMember): + handler → membershipService.RemoveGroupMember(groupID, principalID, principalType) + → validate min owner constraint + → s.removeAllPolicies(groupID, "app/group", principalID, principalType) + → s.removeRelations(groupID, "app/group", principalID, principalType) + → audit +``` + +**Handler wiring:** +- New `RemoveGroupMember` RPC handler → calls `membershipService.RemoveGroupMember` +- Existing `RemoveGroupUser` RPC handler → rewire to call `membershipService.RemoveGroupMember` (backward compat until deleted) +- Remove min owner check from old handler (now in membership) + +**Explicit relations to clean up on remove:** + +Delete ALL explicit relations for the principal on this group: both `group#owner@principal` and `group#member@principal`. If not cleaned up, the principal retains group access via stale relations even after all policies are deleted. + +**Delete from `core/group/service.go`:** +- `RemoveUsers()` (line ~325) — replaced by membership +- `removeUsers()` (private helper) — replaced by membership + +**Tests:** +- Happy path, not-member error +- Last owner rejection +- Verify both `group#owner` and `group#member` relations are deleted on remove + +--- + +## Part 2: Listing + +### Task 6: `membership.ListPrincipalsByResource` + migrate + +**Context:** Today, listing members OF a resource is scattered: `user.ListByOrg` (policy-based), `user.ListByGroup` (policy-based), `project.ListUsers/ListServiceUsers/ListGroups` (policy-based). On top of that, 6 handlers directly call `policyService.ListRoles()` to enrich responses with role info — bypassing the service layer. This task consolidates all of it into membership. + +**Public function — `ListPrincipalsByResource(ctx, resourceID, resourceType string, filter MemberFilter) ([]Member, error)`:** + +```go +type MemberFilter struct { + PrincipalType string // optional: filter to app/user, app/serviceuser, app/group + RoleIDs []string // optional: filter by specific roles + WithRoles bool // enrich response with role details +} + +type Member struct { + PrincipalID string + PrincipalType string + Roles []role.Role // populated when WithRoles=true +} +``` + +Internally: `policyService.List(resourceID, resourceType, filter...)` → extract principal IDs by type → optionally enrich with `policyService.ListRoles()`. Returns `[]Member` with principal IDs + roles. Entity hydration (fetching full User/ServiceUser/Group objects) stays in the handler. + +**What it replaces — service-level:** + +| Current function | File | What it does | +|---|---|---| +| `user.ListByOrg(orgID, roleFilter)` | `core/user/service.go:157` | `policy.List(orgID)` → extract user IDs → `repo.GetByIDs` | +| `user.ListByGroup(groupID, roleFilter)` | `core/user/service.go:214` | `policy.List(groupID)` → extract user IDs → `repo.GetByIDs` | +| `project.ListUsers(projectID, permFilter)` | `core/project/service.go:247` | `policy.List(projectID)` → extract user IDs → `userService.GetByIDs` | +| `project.ListServiceUsers(projectID, permFilter)` | `core/project/service.go` | `policy.List(projectID)` → extract SU IDs → `suserService.GetByIDs` | +| `project.ListGroups(projectID)` | `core/project/service.go` | `policy.List(projectID)` → extract group IDs → `groupService.GetByIDs` | + +**What it replaces — handler-level direct policyService calls:** + +| Handler | Current direct call | Purpose | +|---|---|---| +| `ListOrganizationUsers` (with role_filters) | `h.policyService.List(ctx, policy.Filter{OrgID, RoleIDs})` | Filter users by role | +| `ListOrganizationUsers` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per user | Enrich user with roles | +| `ListProjectUsers` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per user | Enrich user with roles | +| `ListProjectServiceUsers` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per SU | Enrich SU with roles | +| `ListProjectGroups` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per group | Enrich group with roles | +| `ListGroupUsers` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per user | Enrich user with roles | + +All replaced by `membershipService.ListPrincipalsByResource(resourceID, resourceType, {WithRoles: true})`. + +**Handler rewiring:** +- `ListOrganizationUsers` → `membershipService.ListPrincipalsByResource(orgID, "app/organization", {PrincipalType: "app/user", WithRoles: true, RoleIDs: ...})` then hydrate users +- `ListProjectUsers` → same pattern with `"app/project"` +- `ListProjectServiceUsers` → same pattern with `PrincipalType: "app/serviceuser"` +- `ListProjectGroups` → same pattern with `PrincipalType: "app/group"` +- `ListGroupUsers` → same pattern with `"app/group"` + +**Note:** `ListOrganizationServiceUsers` currently uses `serviceUserService.List(orgID)` which queries Postgres by `org_id` column — NOT policy-based. Decide: switch to policy-based listing (consistent) or leave as-is (it works because serviceusers always belong to one org). If Task 1 adds a policy for serviceuser→org membership, then policy-based listing will work for serviceusers too. + +**Delete:** +- `user.ListByOrg()` (`core/user/service.go:157`) — replaced +- `user.ListByGroup()` (`core/user/service.go:214`) — replaced +- `user.getUserIDsFromPolicies()` (helper) — replaced +- `project.ListUsers()` (`core/project/service.go:247`) — replaced +- `project.ListServiceUsers()` — replaced +- `project.ListGroups()` — replaced +- Remove `PolicyService` interface from user service if no other user functions use it +- Remove handler-level `policyService` dependency from handlers that only used it for ListRoles + +**Tests:** +- List users of org with role filter +- List users of project with role enrichment +- List serviceusers of project +- List groups of project +- Empty results when no members + +--- + +### Task 7: `membership.ListResourcesByPrincipal` + migrate + +**Context:** Today, `org.ListByUser`, `project.ListByUser`, `group.ListByUser` all use `relation.LookupResources` (SpiceDB) which reads the `membership` permission — this reads stale direct relations, the root cause of the permission bug. This task switches to policy-based listing so the single source of truth is policies. + +**Public function — `ListResourcesByPrincipal(ctx, principalID, principalType, resourceType string) ([]string, error)`:** + +Internally: `policyService.List({PrincipalID, PrincipalType, ResourceType})` → extract unique resource IDs. Returns list of resource IDs (org IDs, project IDs, or group IDs). + +**What it replaces:** + +| Current function | File | What it does (buggy) | +|---|---|---| +| `org.ListByUser(principal, filter)` | `core/organization/service.go:314` | `relation.LookupResources("app/organization", principal, "membership")` — reads SpiceDB, includes stale relations | +| `project.ListByUser(principal, filter)` | `core/project/service.go:151` | `relation.LookupResources("app/project", principal, "membership")` — same | +| `group.ListByUser(principal, filter)` | `core/group/service.go:129` | `relation.LookupResources("app/group", principal, "membership")` — same | + +**Special cases to handle:** +- **PAT scope:** `org.ListByUser` intersects with `principal.PAT.OrgID`. `project.ListByUser` and `group.ListByUser` call `intersectPATScope`. This logic moves into membership or stays in the caller — decide during implementation. +- **NonInherited filter:** `project.ListByUser` has a `NonInherited` flag that calls `listNonInheritedProjectIDs` to list only directly-added projects (not inherited via org). Policy-based listing naturally handles this since policies are explicit per resource. + +**Callers to update:** + +These service functions are called by aggregate handlers and other internal callers: +- `ListOrganizationsByUser` / `ListOrganizationsByCurrentUser` RPC handlers +- `ListProjectsByUser` / `ListProjectsByCurrentUser` RPC handlers +- `ListUserGroups` / `ListCurrentUserGroups` RPC handlers +- `SearchUserOrganizations`, `SearchUserProjects` aggregate handlers +- `deleterService.DeleteUser()` calls `orgService.ListByUser` to find user's orgs — update to call membership + +**Delete:** +- `org.ListByUser()` (`core/organization/service.go:314`) — replaced +- `project.ListByUser()` (`core/project/service.go:151`) — replaced +- `project.listNonInheritedProjectIDs()` (helper) — moves into membership or removed +- `group.ListByUser()` (`core/group/service.go:129`) — replaced +- Remove `RelationService.LookupResources` from org/project/group service interfaces if no longer needed + +**This is the change that makes policies the single source of truth for "what resources does this principal have access to."** After this, stale SpiceDB relations no longer affect listing. + +**Tests:** +- List orgs for user — returns only orgs where user has policy +- List projects for user — returns only projects where user has direct policy +- PAT scoping — only returns resources within PAT's org +- Verify the bug fix: user with stale relation but no policy → NOT returned + +--- + +## After all tasks — RPCs to delete + +Separate cleanup task or tracked in #1478. Only delete after all consumers (including SDK) have migrated. + +| Delete from proto | Replaced by | Safe to delete when | +|---|---|---| +| `AddOrganizationUsers` | `AddOrganizationMember` | All consumers migrated (Task 1) | +| `RemoveOrganizationUser` | `RemoveOrganizationMember` | All consumers migrated (Task 2) | +| `AddGroupUsers` | `SetGroupMemberRole` | All consumers migrated (Task 4) | +| `RemoveGroupUser` | `RemoveGroupMember` | All consumers migrated (Task 5) | + +## Service functions deleted after all tasks + +| Package | Deleted functions | Replaced by | +|---|---|---| +| `core/organization` | `AddMember`, `AddUsers`, `SetMemberRole`, `RemoveUsers`, `ListByUser`, `validateSetMemberRoleRequest`, `getUserOrgPolicies`, `validateMinOwnerConstraint`, `replaceUserOrgPolicies` | membership package | +| `core/project` | `SetMemberRole`, `RemoveMember`, `ListByUser`, `ListUsers`, `ListServiceUsers`, `ListGroups`, `validatePrincipal`, `validateProjectRole`, `listNonInheritedProjectIDs` | membership package | +| `core/group` | `AddMember`, `addOwner`, `AddUsers`, `addMemberPolicy`, `RemoveUsers`, `removeUsers`, `ListByUser` | membership package | +| `core/user` | `ListByOrg`, `ListByGroup`, `getUserIDsFromPolicies` | membership package | +| `core/deleter` | `RemoveUsersFromOrg` cascade logic | membership package | diff --git a/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx b/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx index 9c3ed2482..2bf1b937a 100644 --- a/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx +++ b/web/sdk/react/views-new/projects/components/remove-member-dialog.tsx @@ -19,6 +19,7 @@ import { handleConnectError } from '~/utils/error'; export interface RemoveMemberPayload { memberId: string; projectId: string; + memberType?: 'user' | 'group'; } type AlertDialogHandle = ReturnType< @@ -78,7 +79,7 @@ function RemoveMemberForm({ create(RemoveProjectMemberRequestSchema, { projectId: payload.projectId, principalId: payload.memberId, - principalType: 'app/user' + principalType: payload.memberType === 'group' ? 'app/group' : 'app/user' }) ); toastManager.add({ title: 'Member removed', type: 'success' }); diff --git a/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx b/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx index 9b7a978c0..e967098f1 100644 --- a/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx +++ b/web/sdk/react/views/api-keys/details/manage-service-user-projects-dialog.tsx @@ -220,7 +220,7 @@ export default function ManageServiceUserProjectsDialog({ })); } }, - [serviceUserId, setProjectMemberRole, removeProjectMember] + [serviceUserId, ownerRoleId, setProjectMemberRole, removeProjectMember] ); const columns = getColumns({ diff --git a/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx b/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx index afeb5dabc..78e880c42 100644 --- a/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx +++ b/web/sdk/react/views/api-keys/list/add-service-account-dialog.tsx @@ -128,6 +128,10 @@ export const AddServiceAccountDialog = ({ const onSubmit = useCallback( async (data: FormData) => { if (!orgId) return; + if (!ownerRoleId) { + toast.error('Project owner role not found'); + return; + } try { const serviceUserResponse = await createServiceUser( @@ -141,8 +145,6 @@ export const AddServiceAccountDialog = ({ const serviceUserId = serviceUserResponse.serviceuser?.id; if (!serviceUserId) return; - - if (!ownerRoleId) throw new Error('Project owner role not found'); await setProjectMemberRole( create(SetProjectMemberRoleRequestSchema, { projectId: data.project_id, @@ -200,6 +202,7 @@ export const AddServiceAccountDialog = ({ }, [ orgId, + ownerRoleId, createServiceUser, setProjectMemberRole, createServiceUserToken, From a75aad8093716d75e5e68faf1d4edb896fb75092 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Wed, 8 Apr 2026 12:05:31 +0530 Subject: [PATCH 06/16] fix: pass memberType through remove member flow for group support Both views and views-new now pass the member type (user/group) from the column action through to the remove dialog and into the RemoveProjectMember RPC call. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/sdk/react/views-new/projects/project-details-view.tsx | 3 ++- .../react/views/projects/details/project-detail-page.tsx | 8 +++++--- .../views/projects/details/project-member-columns.tsx | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/web/sdk/react/views-new/projects/project-details-view.tsx b/web/sdk/react/views-new/projects/project-details-view.tsx index ba9e4d31d..332cd3b96 100644 --- a/web/sdk/react/views-new/projects/project-details-view.tsx +++ b/web/sdk/react/views-new/projects/project-details-view.tsx @@ -447,7 +447,8 @@ export function ProjectDetailsView({ payload && removeMemberDialogHandle.openWithPayload({ memberId: payload.memberId, - projectId + projectId, + memberType: payload.isTeam ? 'group' : 'user' }) } data-test-id="frontier-sdk-remove-member-btn" diff --git a/web/sdk/react/views/projects/details/project-detail-page.tsx b/web/sdk/react/views/projects/details/project-detail-page.tsx index b5cca8daf..535167503 100644 --- a/web/sdk/react/views/projects/details/project-detail-page.tsx +++ b/web/sdk/react/views/projects/details/project-detail-page.tsx @@ -48,7 +48,8 @@ export const ProjectDetailPage = ({ const [deleteProjectState, setDeleteProjectState] = useState({ open: false }); const [removeMemberState, setRemoveMemberState] = useState({ open: false, - memberId: '' + memberId: '', + memberType: 'user' as 'user' | 'group' }); const { @@ -190,8 +191,8 @@ export const ProjectDetailPage = ({ } }; - const onRemoveMember = (memberId: string) => { - setRemoveMemberState({ open: true, memberId }); + const onRemoveMember = (memberId: string, memberType: 'user' | 'group' = 'user') => { + setRemoveMemberState({ open: true, memberId, memberType }); }; return ( @@ -257,6 +258,7 @@ export const ProjectDetailPage = ({ onOpenChange={handleRemoveMemberOpenChange} projectId={projectId} memberId={removeMemberState.memberId} + memberType={removeMemberState.memberType} /> ); diff --git a/web/sdk/react/views/projects/details/project-member-columns.tsx b/web/sdk/react/views/projects/details/project-member-columns.tsx index 69075c6cd..4e5ce131a 100644 --- a/web/sdk/react/views/projects/details/project-member-columns.tsx +++ b/web/sdk/react/views/projects/details/project-member-columns.tsx @@ -38,7 +38,7 @@ export const getColumns = ( canUpdateProject: boolean, projectId: string, refetch: () => void, - onRemoveMember?: (memberId: string) => void + onRemoveMember?: (memberId: string, memberType: 'user' | 'group') => void ): DataTableColumnDef[] => [ { header: '', @@ -149,10 +149,10 @@ const MembersActions = ({ canUpdateProject?: boolean; excludedRoles: Role[]; refetch: () => void; - onRemoveMember?: (memberId: string) => void; + onRemoveMember?: (memberId: string, memberType: 'user' | 'group') => void; }) => { function removeMember() { - onRemoveMember?.(member?.id as string); + onRemoveMember?.(member?.id as string, member?.isTeam ? 'group' : 'user'); } const { mutateAsync: setMemberRole } = useMutation( From cef814eeafefaf6b17ff9aacb8e5ccca3a466521 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Wed, 8 Apr 2026 12:09:54 +0530 Subject: [PATCH 07/16] chore: remove accidentally added doc file Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/membership-package-tasks.md | 762 ------------------------------- 1 file changed, 762 deletions(-) delete mode 100644 docs/membership-package-tasks.md diff --git a/docs/membership-package-tasks.md b/docs/membership-package-tasks.md deleted file mode 100644 index 83d703bbc..000000000 --- a/docs/membership-package-tasks.md +++ /dev/null @@ -1,762 +0,0 @@ -# Membership Package — Task Breakdown - -Parent issue: [#1478](https://github.com/raystack/frontier/issues/1478) -Prior work: [#1459](https://github.com/raystack/frontier/issues/1459) (SetOrganizationMemberRole), [#1461](https://github.com/raystack/frontier/issues/1461) (SetProjectMemberRole + RemoveProjectMember) - ---- - -## Executive Summary - -### What we are doing - -Introducing a `core/membership` package that becomes the **single owner** of all member access management across organizations, projects, and groups. Today, membership logic (adding members, changing roles, removing members, listing members) is scattered across 5 packages (`organization`, `project`, `group`, `user`, `serviceuser`) with each independently calling `policyService` and `relationService`. The membership package centralizes this into one place with atomic guarantees. - -### Why - -Frontier manages access through two mechanisms: **policies** (role bindings that create 3 SpiceDB relations internally) and **direct relations** (explicit SpiceDB relations like `org#owner@user`). Today, the code that manages these is scattered across `organization`, `project`, `group`, `user`, and `serviceuser` packages — each with its own way of creating policies, relations, or both. Some create both, some create only one, some forget to clean up on role changes. This scattered surface area is the root cause of leaky relations: there are too many code paths that can create or modify access, and no single place that enforces consistency. The most visible bug: demoting an org owner to viewer replaces the policy but the code path never touches the `org#owner@user` relation, so the user retains owner access. - -### What we achieve - -- **Bug fix:** Every mutation (add/set/remove) updates both policy and relation through a single code path — no more stale permissions from scattered call sites -- **Single source of truth:** All reads (list members, list resources for a user) go through policies, not SpiceDB relations that can be stale -- **Simpler codebase:** Organization, project, group, user, and serviceuser packages become pure CRUD (create, read, update, delete entities). All access management logic moves into a single `membership` package. One package for entities, one package for access — clear separation. -- **Code consolidation:** ~30 service functions across 5 packages collapse into 10 public membership functions + shared private core -- **Consistent API:** New RPCs for org and group follow the same `(resource_id, principal_id, principal_type, role_id)` pattern that project already has -- **All principal types:** Org and group RPCs gain support for serviceusers and groups as principals (today they only support users) -- **Explicit roles:** `AddOrganizationMember` and `SetGroupMemberRole` require an explicit role (today `AddOrganizationUsers` hardcodes viewer, `AddGroupUsers` hardcodes member) -- **Better audit logging:** All access mutations flow through one package, giving fine-grained control over what gets logged. Today audit records are inconsistently created — some code paths log, some don't. With membership owning all access changes, every add/set/remove can produce a consistent audit trail with uniform event types and metadata - -### High-level approach - -``` -Before: - handler → orgService.AddMember() → policyService.Create() + relationService.Create() [not atomic] - handler → orgService.SetMemberRole() → policyService.Delete/Create() [no relation update] - handler → orgService.RemoveUsers() → policyService.Delete() + relationService.Delete() [not atomic] - -After: - handler → membershipService.AddOrganizationMember() → replacePolicy() + replaceRelation() [atomic] - handler → membershipService.SetOrganizationMemberRole() → replacePolicy() + replaceRelation() [atomic] - handler → membershipService.RemoveOrganizationMember() → removeAllPolicies() + removeRelations() [atomic] -``` - -The membership package has **public functions per resource type** (org/project/group) for type-safe, validation-specific entry points, and **private shared core** (`replacePolicy`, `replaceRelation`, `removeAllPolicies`, `removeRelations`) that handles the atomic policy+relation operations. - -### What gets added - -| Addition | Count | -|---|---| -| New package: `core/membership/` | 1 | -| New public functions (SetRole, Remove, List for org/project/group) | ~10 | -| New proto RPCs (`AddOrganizationMember`, `RemoveOrganizationMember`, `SetGroupMemberRole`, `RemoveGroupMember`) | 4 | - -### What gets deleted - -| Deletion | Count | -|---|---| -| Service functions removed from `core/organization` | 9 (`AddMember`, `AddUsers`, `SetMemberRole`, `RemoveUsers`, `ListByUser`, + 4 helpers) | -| Service functions removed from `core/project` | 9 (`SetMemberRole`, `RemoveMember`, `ListByUser`, `ListUsers`, `ListServiceUsers`, `ListGroups`, + 3 helpers) | -| Service functions removed from `core/group` | 7 (`AddMember`, `addOwner`, `AddUsers`, `addMemberPolicy`, `RemoveUsers`, `removeUsers`, `ListByUser`) | -| Service functions removed from `core/user` | 3 (`ListByOrg`, `ListByGroup`, `getUserIDsFromPolicies`) | -| Cascade logic removed from `core/deleter` | 1 (`RemoveUsersFromOrg`) | -| Old proto RPCs deprecated then deleted | 4 (`AddOrganizationUsers`, `RemoveOrganizationUser`, `AddGroupUsers`, `RemoveGroupUser`) | -| **Total functions removed** | **~29** | - -### Task overview - -| # | Task | New proto? | Priority | -|---|---|---|---| -| 1 | `AddOrganizationMember` — create package + org add/set + serviceuser migration | Yes | High | -| 2 | `RemoveOrganizationMember` — org remove + cascade | Yes | High | -| 3 | Project member mutations — rewire existing RPCs | No | High | -| 4 | `SetGroupMemberRole` — group add/set | Yes | Last | -| 5 | `RemoveGroupMember` — group remove | Yes | Last | -| 6 | `ListPrincipalsByResource` — list members of a resource | No | Medium | -| 7 | `ListResourcesByPrincipal` — list resources for a user | No | Medium | - -### What changes for the SDK - -- **No more policy/relation RPCs for member management:** The SDK stops using `CreatePolicy`, `DeletePolicy`, `ListPolicies`, `CreateRelation`, `DeleteRelation` for adding/removing/changing members. These were low-level building blocks that leaked internal implementation details (policy IDs, relation triples, principal string formatting) to the client. The SDK now works entirely with role-based RPCs: `AddOrganizationMember`, `SetOrganizationMemberRole`, `RemoveOrganizationMember`, `SetProjectMemberRole`, `RemoveProjectMember`, and the new group equivalents. -- **Explicit roles everywhere:** `AddOrganizationMember` requires a `role_id` — no more hidden defaults. The SDK fetches available roles via `ListRoles(scope)` and passes the chosen role UUID. Same pattern already working for project (migrated in #1461). -- **All principal types from one RPC:** The SDK can add users, service users, and groups to any resource using the same RPC with `principal_type`. Today org and group RPCs only accept `user_ids[]`. -- **Simpler error handling:** One RPC call instead of multi-step `list → delete → create` sequences. No more `Promise.allSettled` workarounds in the React SDK for partial failures during role changes. - -### What changes for developer experience - -- **One package to understand:** A new developer working on member management only needs to read `core/membership/`. Today they'd have to trace through `organization`, `project`, `group`, `user`, `serviceuser`, `deleter`, and handler-level policy calls to understand the full picture. -- **One place to debug:** If a user has unexpected access, you check their policies via the membership package. No need to cross-reference direct SpiceDB relations against policies to find which path is granting access. -- **Hard to get wrong:** Adding member management to a new feature (e.g., supporting service accounts in groups, or adding role-based access to billing accounts) means implementing a few public functions in the membership package following the established pattern. No risk of forgetting the relation, the audit log, or the min-owner check — the pattern is right there. -- **Entity services stay simple:** `core/organization/`, `core/project/`, `core/group/` become straightforward CRUD services. No interleaved access management logic to reason about when modifying entity lifecycle code. - -### Vendor portability (SpiceDB → OpenFGA or similar) - -Today, SpiceDB specifics are spread across every service that calls `relationService.Create`, `relationService.LookupResources`, `policyService.Create` (which internally creates SpiceDB relations). Swapping SpiceDB for another authorization engine (OpenFGA, Ory Keto, custom) would require touching every call site — org, project, group, user, serviceuser, deleter services plus 6+ handlers. - -After this work: -- **Mutations:** All SpiceDB writes for membership go through the membership package's private core (`replacePolicy`, `replaceRelation`). Changing the authorization backend means changing these ~4 private functions. Zero changes to org/project/group services or handlers. -- **Reads:** All membership reads go through `ListPrincipalsByResource` and `ListResourcesByPrincipal`. Swapping the query backend is localized to these 2 functions. -- **Authz checks:** `CheckPermission`/`BatchCheckPermission` are already behind the `relation.AuthzRepository` interface — unrelated to this work, but also a single-point swap. -- **Net effect:** Membership package becomes a clean abstraction boundary. The rest of the codebase talks in terms of "add member with role" / "remove member" / "list members" — it doesn't know or care whether the backing store is SpiceDB, OpenFGA, or a Postgres table. - -### What does NOT change - -- **Authorization checks:** `CheckPermission` and `BatchCheckPermission` continue to work exactly as they do today — they read from SpiceDB's permission graph, which is still populated by policies (via role bindings) and any remaining direct relations. No change to how the SDK or handlers verify access before an action. -- **SpiceDB schema:** No schema changes in this phase. The `owner`, `member`, `granted`, `role`, `bearer` relations all stay as-is. Schema cleanup (removing direct relations) may happen later depending on how much benefit we see — it's not committed. -- **Role definitions:** `core/role/` service stays independent — creating roles, managing permissions on roles, role-permission relations in SpiceDB are untouched. -- **Hierarchy relations:** `project→org`, `group→org`, `serviceuser→org` (identity link), `invitation→org` — these are entity links, not membership. They stay as direct relation calls in their respective services. -- **Platform relations:** `platform→admin`, `platform→member` for superuser access — stays in user service. -- **Billing, preferences, invitations:** Completely unrelated, no changes. -- **Existing List RPCs:** `ListOrganizationUsers`, `ListProjectUsers`, `ListGroupUsers` etc. — same proto, same response shape. The handlers rewire to call the membership package instead of calling policy/user/project services directly (Tasks 6 and 7). - -### Phase 2 (future, if needed) - -Once all call sites go through the membership package, removing direct SpiceDB relations becomes a one-file change inside the package — delete the `replaceRelation`/`removeRelations` calls, update SpiceDB schema, run migration to clean stale relations. No external callers change. Whether we do this depends on the benefits observed after Phase 1 stabilizes. - ---- - -## Part 1: Mutations - -### Task 1: `AddOrganizationMember` — create membership package + proto + impl + migrate - -**Context:** This is the first task — it creates `core/membership/` from scratch. The private shared core (`replacePolicy`, `replaceRelation`, etc.) gets extracted here as it emerges from implementing the first public function. The existing `AddOrganizationUsers` RPC hides the role (hardcodes viewer) and only supports users. The new RPC makes role explicit and supports all principal types. - -**Proto (raystack/proton):** - -Add new RPC to `raystack/frontier/v1beta1/frontier.proto`: - -```protobuf -rpc AddOrganizationMember(AddOrganizationMemberRequest) returns (AddOrganizationMemberResponse); - -message AddOrganizationMemberRequest { - string org_id = 1; - string principal_id = 2; - string principal_type = 3; // app/user, app/serviceuser - string role_id = 4; -} -message AddOrganizationMemberResponse {} -``` - -Do NOT modify or delete `AddOrganizationUsers` — it stays for now. - -**Package creation (`core/membership/`):** - -- `service.go` — Service struct with dependency interfaces: - - `PolicyService` (Create, List, Delete) - - `RelationService` (Create, Delete) - - `RoleService` (Get) - - `OrgRepository` (GetByID — for validation) - - `UserService` (GetByID — for audit) - - `AuditRecordRepository` (Create) -- Private shared core (extracted during implementation, not designed upfront): - - `replacePolicy(ctx, resourceID, resourceType, principalID, principalType, roleID)` — delete existing policies for principal+resource, create new one - - `replaceRelation(ctx, resourceID, resourceType, principalID, principalType, relationName)` — delete existing relations, create new one matching role - - Helper to map roleID to relation name (e.g. `app_organization_owner` → `owner`, `app_organization_viewer` → `member`) -- Wire into bootstrap/DI (`internal/bootstrap/`) - -**Public functions to implement:** - -`AddOrganizationMember(ctx, orgID, principalID, principalType, roleID) error`: -- Validate role is org-scoped (one of `app_organization_viewer`, `app_organization_manager`, `app_organization_owner`, or custom org role) -- List existing policies for principal+org → if any exist → `ErrAlreadyMember` -- `replacePolicy()` — create policy -- `replaceRelation()` — create matching relation (e.g. `org#member@user` for viewer/manager, `org#owner@user` for owner) -- Create audit record (org member added event) - -`SetOrganizationMemberRole(ctx, orgID, principalID, principalType, roleID) error`: -- This is the existing `org.SetMemberRole` logic moved here with enhancements -- Validate role is org-scoped -- List existing policies for principal+org → if none → `ErrNotMember` (must already be a member) -- Validate min owner constraint — if principal is last owner and new role is not owner → `ErrLastOwner` -- `replacePolicy()` — delete old policies + create new one -- `replaceRelation()` — delete old relation + create new one matching role ← **THIS FIXES THE LEAK BUG** (today `org.SetMemberRole` never touches relations) -- Create audit record - -**Difference between Add and Set:** -- `Add` → rejects if already a member (`ErrAlreadyMember`) -- `Set` → rejects if NOT a member (`ErrNotMember`), validates min owner constraint - -**Call chain — AddOrganizationMember (new RPC):** - -``` -Before (AddOrganizationUsers): - handler → orgService.AddUsers(orgID, userIDs[]) - → loop: orgService.AddMember(orgID, "member", principal) - → policyService.Create(role=viewer) // hardcoded default - → relationService.Create(org#member@user) // separate, not atomic - → audit - -After (AddOrganizationMember): - handler → membershipService.AddOrganizationMember(orgID, principalID, principalType, roleID) - → validateOrgRole(roleID) - → listExistingPolicies(orgID, principalID, principalType) - → if len > 0 → ErrAlreadyMember - → s.replacePolicy(orgID, "app/organization", principalID, principalType, roleID) - → s.replaceRelation(orgID, "app/organization", principalID, principalType, derivedRelation) - → audit -``` - -**Call chain — SetOrganizationMemberRole (existing RPC, rewire):** - -``` -Before: - handler → orgService.SetMemberRole(orgID, userID, roleID) - → validateSetMemberRoleRequest() - → getUserOrgPolicies() - → if len == 0 → ErrNotMember - → validateMinOwnerConstraint() - → replaceUserOrgPolicies() // policy only, NO relation update ← BUG - -After: - handler → membershipService.SetOrganizationMemberRole(orgID, userID, "app/user", roleID) - → validateOrgRole(roleID) - → listExistingPolicies(orgID, principalID, principalType) - → if len == 0 → ErrNotMember - → validateMinOwnerConstraint() - → s.replacePolicy(...) // delete old + create new - → s.replaceRelation(...) // delete old + create new ← FIXES BUG - → audit -``` - -**ServiceUser migration (also in this task):** - -``` -Before (serviceuser.Create): - → repo.Create(su) - → relationService.Create(org#member@su) // no policy! - → relationService.Create(su#org@org) // identity link - → relationService.Create(su#user@creator) // identity link - -After: - → repo.Create(su) - → membershipService.AddOrganizationMember(orgID, suID, "app/serviceuser", defaultOrgRole) - → relationService.Create(su#org@org) // identity link stays - → relationService.Create(su#user@creator) // identity link stays -``` - -ServiceUser service keeps `relationService` for identity links only. - -**Handler wiring:** -- New `AddOrganizationMember` RPC handler → calls `membershipService.AddOrganizationMember` -- Existing `SetOrganizationMemberRole` RPC handler (from #1459) → rewire from `orgService.SetMemberRole` to `membershipService.SetOrganizationMemberRole` -- Existing `AddOrganizationUsers` RPC handler → rewire from `orgService.AddUsers` to loop `membershipService.AddOrganizationMember` with default viewer role (backward compat until deleted) - -**Delete from `core/organization/service.go`:** -- `AddMember()` (line ~207) — replaced by `membership.AddOrganizationMember` -- `AddUsers()` (line ~348) — replaced by handler looping `membership.AddOrganizationMember` -- `SetMemberRole()` (line ~361) — replaced by `membership.SetOrganizationMemberRole` -- `validateSetMemberRoleRequest()` — moves into membership -- `getUserOrgPolicies()` — moves into membership -- `validateMinOwnerConstraint()` — moves into membership -- `replaceUserOrgPolicies()` — absorbed by `replacePolicy` private core -- Remove `PolicyService` and `RelationService` interfaces from org service if no other org functions use them (check `Create` org — it creates platform relation + owner policy; these may need to stay or also move) - -**Explicit relations that must be managed in every org mutation:** - -After upserting/deleting the policy, each mutation must also handle these explicit direct relations. If not cleaned up, the old relation continues granting the old permissions — this is the core bug today (e.g., org creator demoted from owner to viewer still retains owner access via stale `org#owner@user` relation). - -| Role | Explicit relation to create | On role change / add, must first delete | -|---|---|---| -| `app_organization_owner` | `org#owner@principal` | Any existing `org#owner` AND `org#member` for this principal | -| `app_organization_manager` | `org#member@principal` | Any existing `org#owner` AND `org#member` for this principal | -| `app_organization_viewer` | `org#member@principal` | Any existing `org#owner` AND `org#member` for this principal | - -On remove: delete ALL explicit relations (`org#owner` and `org#member`) for the principal. - -ServiceUser special case: `serviceuser.Create()` today creates `org#member@serviceuser` with NO policy. After migration, `AddOrganizationMember` creates both the policy and the relation. - -**Schema constants to be aware of:** -- `schema.RoleOrganizationOwner` = `app_organization_owner` -- `schema.RoleOrganizationManager` = `app_organization_manager` -- `schema.RoleOrganizationViewer` = `app_organization_viewer` -- `schema.OwnerRelationName` = `owner` -- `schema.MemberRelationName` = `member` -- `schema.OrganizationNamespace` = `app/organization` -- Org constants: `AdminRole = schema.RoleOrganizationOwner`, `MemberRole = schema.RoleOrganizationViewer` - -**Tests:** -- `AddOrganizationMember`: happy path, already-member error, invalid role error -- `SetOrganizationMemberRole`: happy path, not-member error, last-owner error, relation cleanup -- Verify the bug fix: after `SetOrganizationMemberRole` from owner→viewer, the old `org#owner@user` relation must be gone -- ServiceUser: after creation, serviceuser appears in policy-based listing (has policy now) - ---- - -### Task 2: `RemoveOrganizationMember` — proto + impl + migrate - -**Context:** Current `RemoveOrganizationUser` RPC goes through `deleterService.RemoveUsersFromOrg` which does a cascade: removes user from all org projects, all org groups, then removes org-level policies + org relation. This cascade logic either moves into membership or deleter calls membership at each level. The new RPC supports all principal types, not just users. - -**Proto (raystack/proton):** - -```protobuf -rpc RemoveOrganizationMember(RemoveOrganizationMemberRequest) returns (RemoveOrganizationMemberResponse); - -message RemoveOrganizationMemberRequest { - string org_id = 1; - string principal_id = 2; - string principal_type = 3; -} -message RemoveOrganizationMemberResponse {} -``` - -Do NOT modify or delete `RemoveOrganizationUser` — it stays for now. - -**Public function — `RemoveOrganizationMember(ctx, orgID, principalID, principalType) error`:** -- List existing policies for principal+org → if none → `ErrNotMember` -- Validate min owner constraint — can't remove the last owner -- **Cascade:** list org's projects → call `s.removeAllPolicies` for each project where principal has policies -- **Cascade:** list org's groups → call `s.removeAllPolicies` + `s.removeRelations` for each group where principal has policies -- `removeAllPolicies(orgID, "app/organization", principalID, principalType)` — delete org-level policies -- `removeRelations(orgID, "app/organization", principalID, principalType)` — delete org-level relations -- Create audit record (org member removed event) - -**Private shared core (new in this task):** -- `removeAllPolicies(ctx, resourceID, resourceType, principalID, principalType)` — find and delete all policies for principal+resource -- `removeRelations(ctx, resourceID, resourceType, principalID, principalType)` — delete all relations for principal+resource - -**Decision to make during implementation — cascade approach:** -- Option A: `membership.RemoveOrganizationMember` does cascade internally (calls its own `RemoveProjectMember`/`RemoveGroupMember`) -- Option B: keep cascade in deleter, deleter calls membership at each level -- Option A is cleaner if Tasks 3-5 are done. Option B works regardless of task order. Pick based on what's implemented when you get here. - -**Call chain:** - -``` -Before (RemoveOrganizationUser): - handler → deleterService.RemoveUsersFromOrg(orgID, [userID]) - → list ALL user policies across all resources - → for each policy: - project-level? delete policy if belongs to org's projects - group-level? groupService.RemoveUsers() if belongs to org's groups - resource-level? delete policy if belongs to org's project resources - → orgService.RemoveUsers(orgID, [userID]) - → policyService.List(orgID, userID) + Delete per policy - → relationService.Delete(org#*@user) - → audit - -After (RemoveOrganizationMember): - handler → membershipService.RemoveOrganizationMember(orgID, principalID, principalType) - → validate min owner constraint - → cascade: for each org project → s.removeAllPolicies(projectID, ...) - → cascade: for each org group → s.removeAllPolicies + s.removeRelations(groupID, ...) - → s.removeAllPolicies(orgID, "app/organization", principalID, principalType) - → s.removeRelations(orgID, "app/organization", principalID, principalType) - → audit -``` - -**Handler wiring:** -- New `RemoveOrganizationMember` RPC handler → calls `membershipService.RemoveOrganizationMember` -- Existing `RemoveOrganizationUser` RPC handler → rewire from `deleterService.RemoveUsersFromOrg` to `membershipService.RemoveOrganizationMember` (backward compat until deleted) - -**Delete from `core/organization/service.go`:** -- `RemoveUsers()` (line ~508) — replaced by membership - -**Delete/refactor from `core/deleter/service.go`:** -- `RemoveUsersFromOrg()` (line ~254) — cascade logic absorbed by membership. The `DeleteUser()` method in deleter calls `RemoveUsersFromOrg` — update it to call membership instead. - -**Explicit relations to clean up on remove:** - -Removing an org member must delete ALL explicit relations for that principal across the cascade: -- Org level: delete `org#owner@principal` and `org#member@principal` -- For each org project: no explicit relations (project is clean) — only policies -- For each org group: delete `group#owner@principal` and `group#member@principal` - -If these are not cleaned up, the principal retains access via stale relations even after all policies are deleted. - -**Edge cases to handle:** -- Last owner removal → reject -- Principal has policies at org + project + group level → all must be cleaned -- Resource-level policies under org projects (see deleter line ~308) → also clean up -- Partial failure handling: if removing from one project fails, should we abort or continue? Currently deleter uses `errors.Join` and continues. - -**Tests:** -- Happy path, last-owner error, not-member error -- Cascade: removing user from org also removes their project and group memberships within that org -- Verify both policies AND relations are cleaned up at every level - ---- - -### Task 3: Project member mutations — impl + migrate (no new proto, RPCs from #1461) - -**Context:** Project RPCs (`SetProjectMemberRole`, `RemoveProjectMember`) already exist from #1461 and are already clean — policy only, no direct relations. This task moves the logic from `core/project/service.go` into `core/membership/` for consistency. The private shared core from Task 1 gets reused here. The key project-specific logic is: validate org membership of principal before allowing project access. - -**Public function — `SetProjectMemberRole(ctx, projectID, principalID, principalType, roleID) error`:** -- This is the existing `project.SetMemberRole` moved here -- Validate role is project-scoped (`app_project_owner`, `app_project_manager`, `app_project_viewer`, or custom) -- Resolve project → get parent org ID (needs `ProjectRepository.GetByID` as dependency) -- Validate org membership: principal must have at least one policy on the parent org. If not → `ErrNotOrgMember` (this is `validatePrincipal` logic from project service today) -- Validate principal_type is one of `app/user`, `app/serviceuser`, `app/group` -- `replacePolicy()` — reuse private core from Task 1 -- No relation ops — project stays clean -- Note: this is an **upsert** — works for both adding a new member and changing an existing member's role (unlike org which has separate Add and Set) - -**Public function — `RemoveProjectMember(ctx, projectID, principalID, principalType) error`:** -- This is the existing `project.RemoveMember` moved here -- Validate principal_type -- `removeAllPolicies(projectID, "app/project", principalID, principalType)` — reuse private core -- If nothing removed → `ErrNotMember` - -**Call chain — SetProjectMemberRole:** - -``` -Before: - handler → projectService.SetMemberRole(projectID, principalID, principalType, roleID) - → projectService.Get(projectID) // fetch project → get orgID - → validatePrincipal(orgID, principalID, principalType) // must be org member - → validateProjectRole(roleID) // must be project-scoped - → policyService.List(projectID, principalID) → Delete old → Create new - -After: - handler → membershipService.SetProjectMemberRole(projectID, principalID, principalType, roleID) - → validateProjectRole(roleID) - → resolve project → get orgID - → validateOrgMembership(orgID, principalID, principalType) - → s.replacePolicy(projectID, "app/project", principalID, principalType, roleID) - // no relations — project stays clean -``` - -**Call chain — RemoveProjectMember:** - -``` -Before: - handler → projectService.RemoveMember(projectID, principalID, principalType) - → projectService.Get(projectID) - → validate principalType - → policyService.List → if len == 0 → ErrNotMember - → policyService.Delete per policy - -After: - handler → membershipService.RemoveProjectMember(projectID, principalID, principalType) - → validate principalType - → s.removeAllPolicies(projectID, "app/project", principalID, principalType) - → if nothing removed → ErrNotMember -``` - -**Note on org membership validation:** -`SetProjectMemberRole` must verify the principal is an org member. This means membership package needs a way to check "does this principal have any policy on this org?" — which is `listExistingPolicies(orgID, principalID)`. This is the same helper used in Task 1 for `SetOrganizationMemberRole`'s `ErrNotMember` check. - -**Handler wiring:** -- `SetProjectMemberRole` RPC handler → rewire from `projectService.SetMemberRole` to `membershipService.SetProjectMemberRole` -- `RemoveProjectMember` RPC handler → rewire from `projectService.RemoveMember` to `membershipService.RemoveProjectMember` - -**Delete from `core/project/service.go`:** -- `SetMemberRole()` (line ~365) — moved to membership -- `RemoveMember()` (line ~406) — moved to membership -- `validatePrincipal()` — moves into membership as `validateOrgMembership()` -- `validateProjectRole()` — moves into membership -- Remove `RoleService` interface from project service if unused after deletions -- `PolicyService` and `RelationService` may still be needed for listing functions (Part 2) — keep if so - -**Explicit relations: none.** - -Project has no explicit direct relations for membership — only policies. No relation cleanup needed on add, role change, or remove. This is the clean model. - -**Schema constants:** -- `schema.RoleProjectOwner` = `app_project_owner` -- `schema.RoleProjectManager` = `app_project_manager` -- `schema.RoleProjectViewer` = `app_project_viewer` -- `schema.ProjectNamespace` = `app/project` - -**Tests:** -- `SetProjectMemberRole`: happy path (new member), happy path (role change), not-org-member error, invalid role error, invalid principal_type error -- `RemoveProjectMember`: happy path, not-member error - ---- - -### Task 4: `SetGroupMemberRole` — proto + impl + migrate (last priority) - -**Context:** Current `AddGroupUsers` RPC has no role param (hardcodes `group_member`), only supports users, and creates policy + relation non-atomically. The group also has a separate `addOwner` internal method with hardcoded `group_owner` role. The new RPC unifies both with an explicit role and all principal types. - -**Proto (raystack/proton):** - -```protobuf -rpc SetGroupMemberRole(SetGroupMemberRoleRequest) returns (SetGroupMemberRoleResponse); - -message SetGroupMemberRoleRequest { - string group_id = 1; - string org_id = 2; - string principal_id = 3; - string principal_type = 4; - string role_id = 5; -} -message SetGroupMemberRoleResponse {} -``` - -Do NOT modify or delete `AddGroupUsers` — it stays for now. - -**Public function — `SetGroupMemberRole(ctx, groupID, principalID, principalType, roleID) error`:** -- Validate role is group-scoped (`app_group_owner`, `app_group_member`) -- If principal has existing group policies and is being changed from owner → check min owner constraint -- `replacePolicy()` — reuse private core -- `replaceRelation()` — reuse private core. Relation name depends on role: - - `app_group_owner` → `group#owner@principal` - - `app_group_member` → `group#member@principal` -- Note: group `member` relation stays per #1478 (needed for SpiceDB to resolve group-level policies). `owner` relation is being removed in Phase 2 but created in Phase 1 for backward compat. - -**Call chain:** - -``` -Before (AddGroupUsers): - handler → groupService.AddUsers(groupID, userIDs[]) - → loop: groupService.AddMember(groupID, principal) - → addMemberPolicy(): policyService.Create(role=group_member) // hardcoded - → relationService.Create(group#member@user) // separate - -Before (group.addOwner — called during group creation): - → policyService.Create(role=group_owner) - → relationService.Create(group#owner@user) - -After (SetGroupMemberRole): - handler → membershipService.SetGroupMemberRole(groupID, principalID, principalType, roleID) - → validateGroupRole(roleID) - → listExistingPolicies(groupID, principalID, principalType) - → if changing from owner → validateMinOwnerConstraint() - → s.replacePolicy(groupID, "app/group", principalID, principalType, roleID) - → s.replaceRelation(groupID, "app/group", principalID, principalType, derivedRelation) -``` - -**Note on group creation:** When a group is created, the creator is added as owner via `group.addOwner()`. After migration, group creation should call `membershipService.SetGroupMemberRole(groupID, creatorID, "app/user", groupOwnerRole)`. - -**Handler wiring:** -- New `SetGroupMemberRole` RPC handler → calls `membershipService.SetGroupMemberRole` -- Existing `AddGroupUsers` RPC handler → rewire from `groupService.AddUsers` to loop `membershipService.SetGroupMemberRole` with default `group_member` role (backward compat until deleted) -- Group `Create` handler: wherever `addOwner` was called, call membership instead - -**Delete from `core/group/service.go`:** -- `AddMember()` (line ~169) — replaced by membership -- `addOwner()` (line ~194) — replaced by membership -- `AddUsers()` (line ~311) — replaced by handler looping membership -- `addMemberPolicy()` — absorbed into membership -- Remove `PolicyService` and `RelationService` interfaces from group service if no other group functions use them - -**Explicit relations that must be managed in every group mutation:** - -Same pattern as org — after upserting the policy, the matching explicit relation must be created and any old one deleted. If not cleaned up, role changes have no effect. - -| Role | Explicit relation to create | On role change / add, must first delete | -|---|---|---| -| `app_group_owner` | `group#owner@principal` | Any existing `group#owner` AND `group#member` for this principal | -| `app_group_member` | `group#member@principal` | Any existing `group#owner` AND `group#member` for this principal | - -Note: `group#member` relation is one that stays long-term per #1478 — needed for SpiceDB to resolve group-level policies. `group#owner` relation may be removed in Phase 2. - -**Schema constants:** -- `schema.GroupOwnerRole` = `app_group_owner` -- `schema.GroupMemberRole` = `app_group_member` -- `schema.GroupNamespace` = `app/group` - -**Tests:** -- Add member with explicit role -- Change role (member→owner, owner→member) — verify old relation is deleted, new one created -- Min owner constraint -- Group creation still assigns owner through membership - ---- - -### Task 5: `RemoveGroupMember` — proto + impl + migrate (last priority) - -**Context:** Current `RemoveGroupUser` only supports users. The new RPC supports all principal types and ensures atomic cleanup of both policies and relations. - -**Proto (raystack/proton):** - -```protobuf -rpc RemoveGroupMember(RemoveGroupMemberRequest) returns (RemoveGroupMemberResponse); - -message RemoveGroupMemberRequest { - string group_id = 1; - string org_id = 2; - string principal_id = 3; - string principal_type = 4; -} -message RemoveGroupMemberResponse {} -``` - -Do NOT modify or delete `RemoveGroupUser` — it stays for now. - -**Public function — `RemoveGroupMember(ctx, groupID, principalID, principalType) error`:** -- List existing policies → if none → `ErrNotMember` -- Validate min owner constraint (can't remove last owner) -- `removeAllPolicies(groupID, "app/group", principalID, principalType)` — reuse private core -- `removeRelations(groupID, "app/group", principalID, principalType)` — reuse private core -- Audit record - -**Note:** Min owner check currently happens in the handler (`group.go` line ~424) by calling `userService.ListByGroup(groupID, AdminRole)`. After migration, this moves into the membership function. Membership can check this via `listExistingPolicies` filtered by owner role. - -**Call chain:** - -``` -Before (RemoveGroupUser): - handler → check min owner count via userService.ListByGroup(groupID, AdminRole) - handler → groupService.RemoveUsers(groupID, [userID]) - → policyService.List(groupID, userID) + Delete per policy - → relationService.Delete(group#*@user) - → audit - -After (RemoveGroupMember): - handler → membershipService.RemoveGroupMember(groupID, principalID, principalType) - → validate min owner constraint - → s.removeAllPolicies(groupID, "app/group", principalID, principalType) - → s.removeRelations(groupID, "app/group", principalID, principalType) - → audit -``` - -**Handler wiring:** -- New `RemoveGroupMember` RPC handler → calls `membershipService.RemoveGroupMember` -- Existing `RemoveGroupUser` RPC handler → rewire to call `membershipService.RemoveGroupMember` (backward compat until deleted) -- Remove min owner check from old handler (now in membership) - -**Explicit relations to clean up on remove:** - -Delete ALL explicit relations for the principal on this group: both `group#owner@principal` and `group#member@principal`. If not cleaned up, the principal retains group access via stale relations even after all policies are deleted. - -**Delete from `core/group/service.go`:** -- `RemoveUsers()` (line ~325) — replaced by membership -- `removeUsers()` (private helper) — replaced by membership - -**Tests:** -- Happy path, not-member error -- Last owner rejection -- Verify both `group#owner` and `group#member` relations are deleted on remove - ---- - -## Part 2: Listing - -### Task 6: `membership.ListPrincipalsByResource` + migrate - -**Context:** Today, listing members OF a resource is scattered: `user.ListByOrg` (policy-based), `user.ListByGroup` (policy-based), `project.ListUsers/ListServiceUsers/ListGroups` (policy-based). On top of that, 6 handlers directly call `policyService.ListRoles()` to enrich responses with role info — bypassing the service layer. This task consolidates all of it into membership. - -**Public function — `ListPrincipalsByResource(ctx, resourceID, resourceType string, filter MemberFilter) ([]Member, error)`:** - -```go -type MemberFilter struct { - PrincipalType string // optional: filter to app/user, app/serviceuser, app/group - RoleIDs []string // optional: filter by specific roles - WithRoles bool // enrich response with role details -} - -type Member struct { - PrincipalID string - PrincipalType string - Roles []role.Role // populated when WithRoles=true -} -``` - -Internally: `policyService.List(resourceID, resourceType, filter...)` → extract principal IDs by type → optionally enrich with `policyService.ListRoles()`. Returns `[]Member` with principal IDs + roles. Entity hydration (fetching full User/ServiceUser/Group objects) stays in the handler. - -**What it replaces — service-level:** - -| Current function | File | What it does | -|---|---|---| -| `user.ListByOrg(orgID, roleFilter)` | `core/user/service.go:157` | `policy.List(orgID)` → extract user IDs → `repo.GetByIDs` | -| `user.ListByGroup(groupID, roleFilter)` | `core/user/service.go:214` | `policy.List(groupID)` → extract user IDs → `repo.GetByIDs` | -| `project.ListUsers(projectID, permFilter)` | `core/project/service.go:247` | `policy.List(projectID)` → extract user IDs → `userService.GetByIDs` | -| `project.ListServiceUsers(projectID, permFilter)` | `core/project/service.go` | `policy.List(projectID)` → extract SU IDs → `suserService.GetByIDs` | -| `project.ListGroups(projectID)` | `core/project/service.go` | `policy.List(projectID)` → extract group IDs → `groupService.GetByIDs` | - -**What it replaces — handler-level direct policyService calls:** - -| Handler | Current direct call | Purpose | -|---|---|---| -| `ListOrganizationUsers` (with role_filters) | `h.policyService.List(ctx, policy.Filter{OrgID, RoleIDs})` | Filter users by role | -| `ListOrganizationUsers` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per user | Enrich user with roles | -| `ListProjectUsers` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per user | Enrich user with roles | -| `ListProjectServiceUsers` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per SU | Enrich SU with roles | -| `ListProjectGroups` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per group | Enrich group with roles | -| `ListGroupUsers` (with_roles) | `h.policyService.ListRoles(ctx, ...)` per user | Enrich user with roles | - -All replaced by `membershipService.ListPrincipalsByResource(resourceID, resourceType, {WithRoles: true})`. - -**Handler rewiring:** -- `ListOrganizationUsers` → `membershipService.ListPrincipalsByResource(orgID, "app/organization", {PrincipalType: "app/user", WithRoles: true, RoleIDs: ...})` then hydrate users -- `ListProjectUsers` → same pattern with `"app/project"` -- `ListProjectServiceUsers` → same pattern with `PrincipalType: "app/serviceuser"` -- `ListProjectGroups` → same pattern with `PrincipalType: "app/group"` -- `ListGroupUsers` → same pattern with `"app/group"` - -**Note:** `ListOrganizationServiceUsers` currently uses `serviceUserService.List(orgID)` which queries Postgres by `org_id` column — NOT policy-based. Decide: switch to policy-based listing (consistent) or leave as-is (it works because serviceusers always belong to one org). If Task 1 adds a policy for serviceuser→org membership, then policy-based listing will work for serviceusers too. - -**Delete:** -- `user.ListByOrg()` (`core/user/service.go:157`) — replaced -- `user.ListByGroup()` (`core/user/service.go:214`) — replaced -- `user.getUserIDsFromPolicies()` (helper) — replaced -- `project.ListUsers()` (`core/project/service.go:247`) — replaced -- `project.ListServiceUsers()` — replaced -- `project.ListGroups()` — replaced -- Remove `PolicyService` interface from user service if no other user functions use it -- Remove handler-level `policyService` dependency from handlers that only used it for ListRoles - -**Tests:** -- List users of org with role filter -- List users of project with role enrichment -- List serviceusers of project -- List groups of project -- Empty results when no members - ---- - -### Task 7: `membership.ListResourcesByPrincipal` + migrate - -**Context:** Today, `org.ListByUser`, `project.ListByUser`, `group.ListByUser` all use `relation.LookupResources` (SpiceDB) which reads the `membership` permission — this reads stale direct relations, the root cause of the permission bug. This task switches to policy-based listing so the single source of truth is policies. - -**Public function — `ListResourcesByPrincipal(ctx, principalID, principalType, resourceType string) ([]string, error)`:** - -Internally: `policyService.List({PrincipalID, PrincipalType, ResourceType})` → extract unique resource IDs. Returns list of resource IDs (org IDs, project IDs, or group IDs). - -**What it replaces:** - -| Current function | File | What it does (buggy) | -|---|---|---| -| `org.ListByUser(principal, filter)` | `core/organization/service.go:314` | `relation.LookupResources("app/organization", principal, "membership")` — reads SpiceDB, includes stale relations | -| `project.ListByUser(principal, filter)` | `core/project/service.go:151` | `relation.LookupResources("app/project", principal, "membership")` — same | -| `group.ListByUser(principal, filter)` | `core/group/service.go:129` | `relation.LookupResources("app/group", principal, "membership")` — same | - -**Special cases to handle:** -- **PAT scope:** `org.ListByUser` intersects with `principal.PAT.OrgID`. `project.ListByUser` and `group.ListByUser` call `intersectPATScope`. This logic moves into membership or stays in the caller — decide during implementation. -- **NonInherited filter:** `project.ListByUser` has a `NonInherited` flag that calls `listNonInheritedProjectIDs` to list only directly-added projects (not inherited via org). Policy-based listing naturally handles this since policies are explicit per resource. - -**Callers to update:** - -These service functions are called by aggregate handlers and other internal callers: -- `ListOrganizationsByUser` / `ListOrganizationsByCurrentUser` RPC handlers -- `ListProjectsByUser` / `ListProjectsByCurrentUser` RPC handlers -- `ListUserGroups` / `ListCurrentUserGroups` RPC handlers -- `SearchUserOrganizations`, `SearchUserProjects` aggregate handlers -- `deleterService.DeleteUser()` calls `orgService.ListByUser` to find user's orgs — update to call membership - -**Delete:** -- `org.ListByUser()` (`core/organization/service.go:314`) — replaced -- `project.ListByUser()` (`core/project/service.go:151`) — replaced -- `project.listNonInheritedProjectIDs()` (helper) — moves into membership or removed -- `group.ListByUser()` (`core/group/service.go:129`) — replaced -- Remove `RelationService.LookupResources` from org/project/group service interfaces if no longer needed - -**This is the change that makes policies the single source of truth for "what resources does this principal have access to."** After this, stale SpiceDB relations no longer affect listing. - -**Tests:** -- List orgs for user — returns only orgs where user has policy -- List projects for user — returns only projects where user has direct policy -- PAT scoping — only returns resources within PAT's org -- Verify the bug fix: user with stale relation but no policy → NOT returned - ---- - -## After all tasks — RPCs to delete - -Separate cleanup task or tracked in #1478. Only delete after all consumers (including SDK) have migrated. - -| Delete from proto | Replaced by | Safe to delete when | -|---|---|---| -| `AddOrganizationUsers` | `AddOrganizationMember` | All consumers migrated (Task 1) | -| `RemoveOrganizationUser` | `RemoveOrganizationMember` | All consumers migrated (Task 2) | -| `AddGroupUsers` | `SetGroupMemberRole` | All consumers migrated (Task 4) | -| `RemoveGroupUser` | `RemoveGroupMember` | All consumers migrated (Task 5) | - -## Service functions deleted after all tasks - -| Package | Deleted functions | Replaced by | -|---|---|---| -| `core/organization` | `AddMember`, `AddUsers`, `SetMemberRole`, `RemoveUsers`, `ListByUser`, `validateSetMemberRoleRequest`, `getUserOrgPolicies`, `validateMinOwnerConstraint`, `replaceUserOrgPolicies` | membership package | -| `core/project` | `SetMemberRole`, `RemoveMember`, `ListByUser`, `ListUsers`, `ListServiceUsers`, `ListGroups`, `validatePrincipal`, `validateProjectRole`, `listNonInheritedProjectIDs` | membership package | -| `core/group` | `AddMember`, `addOwner`, `AddUsers`, `addMemberPolicy`, `RemoveUsers`, `removeUsers`, `ListByUser` | membership package | -| `core/user` | `ListByOrg`, `ListByGroup`, `getUserIDsFromPolicies` | membership package | -| `core/deleter` | `RemoveUsersFromOrg` cascade logic | membership package | From d5f99396c06d2b4075209e1b71bfbc9253845d2d Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Thu, 9 Apr 2026 13:12:32 +0530 Subject: [PATCH 08/16] refactor: change admin assign-role from multi-select to single-select SetProjectMemberRole replaces all policies with one role, so the UI should only allow selecting one role. Changed from checkboxes to radio buttons to match the API semantics. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../details/projects/members/assign-role.tsx | 73 ++++++------------- 1 file changed, 22 insertions(+), 51 deletions(-) diff --git a/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx b/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx index b79a0e79c..f3735056d 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx @@ -1,14 +1,13 @@ import { Button, - Checkbox, Dialog, Flex, Label, + Radio, Text, toast, } from "@raystack/apsara"; import styles from "./members.module.css"; -import { useCallback } from "react"; import type { SearchProjectUsersResponse_ProjectUser, Role, @@ -32,9 +31,7 @@ interface AssignRoleProps { } const formSchema = z.object({ - roleIds: z.instanceof(Set).refine((set) => set.size > 0, { - message: "At least one role must be selected", - }), + roleId: z.string().min(1, "A role must be selected"), }); type FormData = z.infer; @@ -46,6 +43,8 @@ export const AssignRole = ({ onRoleUpdate, onClose, }: AssignRoleProps) => { + const currentRoleId = user?.roleIds?.[0] || ""; + const { handleSubmit, watch, @@ -53,7 +52,7 @@ export const AssignRole = ({ formState: { isSubmitting, errors }, } = useForm({ defaultValues: { - roleIds: new Set(user?.roleIds || []), + roleId: currentRoleId, }, resolver: zodResolver(formSchema), }); @@ -62,48 +61,23 @@ export const AssignRole = ({ FrontierServiceQueries.setProjectMemberRole, ); - const roleIds = watch("roleIds"); - - function onCheckedChange(value: boolean | string, roleId?: string) { - if (!roleId) return; - const currentRoles = new Set(roleIds); - - if (value) { - currentRoles.add(roleId); - } else { - currentRoles.delete(roleId); - } - - setValue("roleIds", currentRoles); - } - - const checkRole = useCallback( - (roleId?: string) => { - if (!roleId) return false; - return roleIds?.has(roleId) || false; - }, - [roleIds], - ); + const selectedRoleId = watch("roleId"); const onSubmit = async (data: FormData) => { try { - const assignedRolesArr = Array.from(data.roleIds); - - for (const roleId of assignedRolesArr) { - await setProjectMemberRole( - create(SetProjectMemberRoleRequestSchema, { - projectId, - principalId: user?.id || "", - principalType: "app/user", - roleId, - }), - ); - } + await setProjectMemberRole( + create(SetProjectMemberRoleRequestSchema, { + projectId, + principalId: user?.id || "", + principalType: "app/user", + roleId: data.roleId, + }), + ); if (onRoleUpdate) { onRoleUpdate({ ...user, - roleIds: assignedRolesArr, + roleIds: [data.roleId], } as SearchProjectUsersResponse_ProjectUser); } @@ -132,27 +106,24 @@ export const AssignRole = ({ Taking this action may result in changes in the role which might lead to changes in access of the user. -
+
{roles.map((role) => { const htmlId = `role-${role.id}`; - const checked = checkRole(role.id); return ( - - onCheckedChange(value, role.id) - } + data-test-id={`role-radio-${role.id}`} + checked={selectedRoleId === role.id} + onCheckedChange={() => setValue("roleId", role.id || "")} /> ); })} - {errors.roleIds && ( - {errors.roleIds.message} + {errors.roleId && ( + {errors.roleId.message} )}
From 8910abc95b3dbe3d7d6cfe09e48106e4d4927bd3 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Thu, 9 Apr 2026 13:15:35 +0530 Subject: [PATCH 09/16] chore: remove unintended mock file Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mocks/membership_service.go | 86 ------------------- 1 file changed, 86 deletions(-) delete mode 100644 internal/api/v1beta1connect/mocks/membership_service.go diff --git a/internal/api/v1beta1connect/mocks/membership_service.go b/internal/api/v1beta1connect/mocks/membership_service.go deleted file mode 100644 index 7a81278b0..000000000 --- a/internal/api/v1beta1connect/mocks/membership_service.go +++ /dev/null @@ -1,86 +0,0 @@ -// Code generated by mockery v2.53.5. DO NOT EDIT. - -package mocks - -import ( - context "context" - - mock "github.com/stretchr/testify/mock" -) - -// MembershipService is an autogenerated mock type for the MembershipService type -type MembershipService struct { - mock.Mock -} - -type MembershipService_Expecter struct { - mock *mock.Mock -} - -func (_m *MembershipService) EXPECT() *MembershipService_Expecter { - return &MembershipService_Expecter{mock: &_m.Mock} -} - -// AddOrganizationMember provides a mock function with given fields: ctx, orgID, principalID, principalType, roleID -func (_m *MembershipService) AddOrganizationMember(ctx context.Context, orgID string, principalID string, principalType string, roleID string) error { - ret := _m.Called(ctx, orgID, principalID, principalType, roleID) - - if len(ret) == 0 { - panic("no return value specified for AddOrganizationMember") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) error); ok { - r0 = rf(ctx, orgID, principalID, principalType, roleID) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// MembershipService_AddOrganizationMember_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddOrganizationMember' -type MembershipService_AddOrganizationMember_Call struct { - *mock.Call -} - -// AddOrganizationMember is a helper method to define mock.On call -// - ctx context.Context -// - orgID string -// - principalID string -// - principalType string -// - roleID string -func (_e *MembershipService_Expecter) AddOrganizationMember(ctx interface{}, orgID interface{}, principalID interface{}, principalType interface{}, roleID interface{}) *MembershipService_AddOrganizationMember_Call { - return &MembershipService_AddOrganizationMember_Call{Call: _e.mock.On("AddOrganizationMember", ctx, orgID, principalID, principalType, roleID)} -} - -func (_c *MembershipService_AddOrganizationMember_Call) Run(run func(ctx context.Context, orgID string, principalID string, principalType string, roleID string)) *MembershipService_AddOrganizationMember_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string), args[4].(string)) - }) - return _c -} - -func (_c *MembershipService_AddOrganizationMember_Call) Return(_a0 error) *MembershipService_AddOrganizationMember_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *MembershipService_AddOrganizationMember_Call) RunAndReturn(run func(context.Context, string, string, string, string) error) *MembershipService_AddOrganizationMember_Call { - _c.Call.Return(run) - return _c -} - -// NewMembershipService creates a new instance of MembershipService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMembershipService(t interface { - mock.TestingT - Cleanup(func()) -}) *MembershipService { - mock := &MembershipService{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} From 4ebcd8ea6d2a70664296eeec9b18c7e7ea0856a1 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Thu, 9 Apr 2026 13:50:53 +0530 Subject: [PATCH 10/16] fix: use Checkbox with single-select logic instead of Radio Radio component is unstable. Use Checkbox but selecting one automatically deselects others (single-select behavior). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../details/projects/members/assign-role.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx b/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx index f3735056d..04f1a9b9a 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/assign-role.tsx @@ -1,9 +1,9 @@ import { Button, + Checkbox, Dialog, Flex, Label, - Radio, Text, toast, } from "@raystack/apsara"; @@ -106,17 +106,20 @@ export const AssignRole = ({ Taking this action may result in changes in the role which might lead to changes in access of the user. -
+
{roles.map((role) => { const htmlId = `role-${role.id}`; + const checked = selectedRoleId === role.id; return ( - setValue("roleId", role.id || "")} + data-test-id={`role-checkbox-${role.id}`} + checked={checked} + onCheckedChange={() => + setValue("roleId", role.id || "") + } /> From 8cb9637419db16babaecd3b8c54434ba071a0476 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Fri, 10 Apr 2026 10:39:16 +0530 Subject: [PATCH 11/16] fix: migrate admin AssignRole dialog from raw policy CRUD to SetOrganizationMemberRole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace listPolicies → deletePolicy × N → createPolicy × N pattern with a single SetOrganizationMemberRole call. Changes multi-role checkboxes to single-role selection since org membership is one role per user. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/sdk/admin/components/AssignRole.tsx | 92 ++++++------------------- 1 file changed, 20 insertions(+), 72 deletions(-) diff --git a/web/sdk/admin/components/AssignRole.tsx b/web/sdk/admin/components/AssignRole.tsx index 7c5e37212..8bc7a7c87 100644 --- a/web/sdk/admin/components/AssignRole.tsx +++ b/web/sdk/admin/components/AssignRole.tsx @@ -7,26 +7,19 @@ import { Text, toast, } from "@raystack/apsara"; -import { useCallback } from "react"; import type { SearchOrganizationUsersResponse_OrganizationUser, Role, - Policy, } from "@raystack/proton/frontier"; import { - FrontierService, FrontierServiceQueries, - ListPoliciesRequestSchema, - DeletePolicyRequestSchema, - CreatePolicyRequestSchema, + SetOrganizationMemberRoleRequestSchema, } from "@raystack/proton/frontier"; import { create } from "@bufbuild/protobuf"; -import { useMutation, useTransport } from "@connectrpc/connect-query"; -import { createClient } from "@connectrpc/connect"; +import { useMutation } from "@connectrpc/connect-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { SCOPES } from "../utils/constants"; interface AssignRoleProps { organizationId: string; @@ -37,9 +30,7 @@ interface AssignRoleProps { } const formSchema = z.object({ - roleIds: z.instanceof(Set).refine((set) => set.size > 0, { - message: "At least one role must be selected", - }), + roleId: z.string().min(1, "A role must be selected"), }); type FormData = z.infer; @@ -51,7 +42,7 @@ export const AssignRole = ({ onRoleUpdate, onClose, }: AssignRoleProps) => { - const transport = useTransport(); + const currentRoleId = user?.roleIds?.[0] || ""; const { handleSubmit, @@ -60,81 +51,38 @@ export const AssignRole = ({ formState: { isSubmitting, errors }, } = useForm({ defaultValues: { - roleIds: new Set(user?.roleIds || []), + roleId: currentRoleId, }, resolver: zodResolver(formSchema), }); - const { mutateAsync: deletePolicy } = useMutation( - FrontierServiceQueries.deletePolicy, + const { mutateAsync: setMemberRole } = useMutation( + FrontierServiceQueries.setOrganizationMemberRole, ); - const { mutateAsync: createPolicy } = useMutation( - FrontierServiceQueries.createPolicy, - ); - - const roleIds = watch("roleIds"); + const selectedRoleId = watch("roleId"); function onCheckedChange(value: boolean | string, roleId?: string) { if (!roleId) return; - const currentRoles = new Set(roleIds); - if (value) { - currentRoles.add(roleId); - } else { - currentRoles.delete(roleId); + setValue("roleId", roleId); } - - setValue("roleIds", currentRoles); } - const checkRole = useCallback( - (roleId?: string) => { - if (!roleId) return false; - return roleIds?.has(roleId) || false; - }, - [roleIds], - ); - const onSubmit = async (data: FormData) => { try { - const client = createClient(FrontierService, transport); - const policiesResp = await client.listPolicies( - create(ListPoliciesRequestSchema, { + if (data.roleId === currentRoleId) { + onClose(); + return; + } + + await setMemberRole( + create(SetOrganizationMemberRoleRequestSchema, { orgId: organizationId, userId: user?.id, + roleId: data.roleId, }), ); - const policies = policiesResp.policies || []; - - const removedRolesPolicies = policies.filter( - (policy: Policy) => !(policy.roleId && data.roleIds.has(policy.roleId)), - ); - await Promise.all( - removedRolesPolicies.map((policy: Policy) => - deletePolicy( - create(DeletePolicyRequestSchema, { id: policy.id || "" }), - ), - ), - ); - - const resource = `${SCOPES.ORG}:${organizationId}`; - const principal = `${SCOPES.USER}:${user?.id}`; - - const assignedRolesArr = Array.from(data.roleIds); - await Promise.all( - assignedRolesArr.map((roleId) => - createPolicy( - create(CreatePolicyRequestSchema, { - body: { - roleId, - resource, - principal, - }, - }), - ), - ), - ); if (onRoleUpdate) { onRoleUpdate(); @@ -165,7 +113,7 @@ export const AssignRole = ({ {roles.map((role) => { const htmlId = `role-${role.id}`; - const checked = checkRole(role.id); + const checked = selectedRoleId === role.id; return ( ); })} - {errors.roleIds && ( - {errors.roleIds.message} + {errors.roleId && ( + {errors.roleId.message} )}
From 6b6d94527496cef46e11331897f00eea4de8b279 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Fri, 10 Apr 2026 14:18:19 +0530 Subject: [PATCH 12/16] fix: make memberType required in RemoveProjectMemberDialog Prevents silent misuse where group removals would default to 'app/user' principal type and fail. Also fix reset state to include memberType. Co-Authored-By: Claude Opus 4.6 (1M context) --- web/sdk/react/views/projects/details/project-detail-page.tsx | 2 +- .../views/projects/details/remove-project-member-dialog.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/sdk/react/views/projects/details/project-detail-page.tsx b/web/sdk/react/views/projects/details/project-detail-page.tsx index 535167503..f4b7ddba5 100644 --- a/web/sdk/react/views/projects/details/project-detail-page.tsx +++ b/web/sdk/react/views/projects/details/project-detail-page.tsx @@ -184,7 +184,7 @@ export const ProjectDetailPage = ({ const handleRemoveMemberOpenChange = (value: boolean) => { if (!value) { - setRemoveMemberState({ open: false, memberId: '' }); + setRemoveMemberState({ open: false, memberId: '', memberType: 'user' }); refetchTeamAndMembers(); } else { setRemoveMemberState(prev => ({ ...prev, open: value })); diff --git a/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx b/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx index 23a7f6a65..9ef876920 100644 --- a/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx +++ b/web/sdk/react/views/projects/details/remove-project-member-dialog.tsx @@ -22,7 +22,7 @@ export interface RemoveProjectMemberDialogProps { onOpenChange?: (value: boolean) => void; projectId: string; memberId: string; - memberType?: 'user' | 'group'; + memberType: 'user' | 'group'; } export const RemoveProjectMemberDialog = ({ @@ -30,7 +30,7 @@ export const RemoveProjectMemberDialog = ({ onOpenChange, projectId, memberId, - memberType = 'user' + memberType }: RemoveProjectMemberDialogProps) => { const queryClient = useQueryClient(); const transport = useTransport(); From 05823c480627af75c4de15b95271d76c92c396d1 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Fri, 10 Apr 2026 14:27:06 +0530 Subject: [PATCH 13/16] Revert "fix: skip redundant policy delete+create when role is unchanged" This reverts commit 8b12c65170046b72dec37c1a8ace526acdd58724. --- core/organization/service.go | 5 ----- core/project/service.go | 5 ----- 2 files changed, 10 deletions(-) diff --git a/core/organization/service.go b/core/organization/service.go index de6c6180f..4345f9cb0 100644 --- a/core/organization/service.go +++ b/core/organization/service.go @@ -393,11 +393,6 @@ func (s Service) SetMemberRole(ctx context.Context, orgID, userID, newRoleID str return ErrNotMember } - // skip if the user already has exactly this role - if len(existingPolicies) == 1 && existingPolicies[0].RoleID == newRoleID { - return nil - } - // check minimum owner constraint err = s.validateMinOwnerConstraint(ctx, orgID, newRoleID, existingPolicies) if err != nil { diff --git a/core/project/service.go b/core/project/service.go index abd00c480..6334938eb 100644 --- a/core/project/service.go +++ b/core/project/service.go @@ -385,11 +385,6 @@ func (s Service) SetMemberRole(ctx context.Context, projectID, principalID, prin return err } - // skip if the principal already has exactly this role - if len(existingPolicies) == 1 && existingPolicies[0].RoleID == newRoleID { - return nil - } - for _, p := range existingPolicies { if err := s.policyService.Delete(ctx, p.ID); err != nil { return err From 8583b0d7b1f930a190e8d13a8f8e631dc2f1c946 Mon Sep 17 00:00:00 2001 From: Abhishek Sah Date: Fri, 10 Apr 2026 14:55:40 +0530 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20use=20constants,=20isDirty,=20and=20early=20returns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use isDirty from react-hook-form instead of manual comparison for AssignRole dialogs (org + project), disable Update button when unchanged - Replace hardcoded principal type strings with SCOPES/PRINCIPAL_TYPES constants across admin and react SDK - Early return instead of throwing error when ownerRoleId is missing - Disable create button when ownerRoleId is not available Co-Authored-By: Claude Opus 4.6 (1M context) --- web/sdk/admin/components/AssignRole.tsx | 10 +++------- .../details/projects/members/assign-role.tsx | 10 +++++++--- .../details/projects/use-add-project-members.tsx | 6 +++--- .../projects/components/remove-member-dialog.tsx | 7 ++++++- .../details/manage-service-user-projects-dialog.tsx | 5 ++++- .../views/api-keys/list/add-service-account-dialog.tsx | 2 +- .../views/projects/details/project-member-columns.tsx | 7 ++++++- .../projects/details/remove-project-member-dialog.tsx | 7 ++++++- 8 files changed, 36 insertions(+), 18 deletions(-) diff --git a/web/sdk/admin/components/AssignRole.tsx b/web/sdk/admin/components/AssignRole.tsx index 8bc7a7c87..3405c44b4 100644 --- a/web/sdk/admin/components/AssignRole.tsx +++ b/web/sdk/admin/components/AssignRole.tsx @@ -48,7 +48,7 @@ export const AssignRole = ({ handleSubmit, watch, setValue, - formState: { isSubmitting, errors }, + formState: { isSubmitting, errors, isDirty }, } = useForm({ defaultValues: { roleId: currentRoleId, @@ -65,17 +65,12 @@ export const AssignRole = ({ function onCheckedChange(value: boolean | string, roleId?: string) { if (!roleId) return; if (value) { - setValue("roleId", roleId); + setValue("roleId", roleId, { shouldDirty: true }); } } const onSubmit = async (data: FormData) => { try { - if (data.roleId === currentRoleId) { - onClose(); - return; - } - await setMemberRole( create(SetOrganizationMemberRoleRequestSchema, { orgId: organizationId, @@ -148,6 +143,7 @@ export const AssignRole = ({