feat: Add Environment Deletion Permission Control-#2594
This commit is contained in:
Mauricio Siu
2025-10-05 00:26:54 -06:00
committed by GitHub
9 changed files with 6760 additions and 49 deletions

View File

@@ -63,14 +63,6 @@ export const AdvancedEnvironmentSelector = ({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
// API mutations
const { data: environment } = api.environment.one.useQuery(
{ environmentId: currentEnvironmentId || "" },
{
enabled: !!currentEnvironmentId,
},
);
// Get current user's permissions // Get current user's permissions
const { data: currentUser } = api.user.get.useQuery(); const { data: currentUser } = api.user.get.useQuery();
@@ -80,6 +72,12 @@ export const AdvancedEnvironmentSelector = ({
currentUser?.role === "admin" || currentUser?.role === "admin" ||
currentUser?.canCreateEnvironments === true; currentUser?.canCreateEnvironments === true;
// Check if user can delete environments
const canDeleteEnvironments =
currentUser?.role === "owner" ||
currentUser?.role === "admin" ||
currentUser?.canDeleteEnvironments === true;
const haveServices = const haveServices =
selectedEnvironment && selectedEnvironment &&
((selectedEnvironment?.mariadb?.length || 0) > 0 || ((selectedEnvironment?.mariadb?.length || 0) > 0 ||
@@ -276,17 +274,19 @@ export const AdvancedEnvironmentSelector = ({
<PencilIcon className="h-3 w-3" /> <PencilIcon className="h-3 w-3" />
</Button> </Button>
<Button {canDeleteEnvironments && (
variant="ghost" <Button
size="sm" variant="ghost"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700" size="sm"
onClick={(e) => { className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
e.stopPropagation(); onClick={(e) => {
openDeleteDialog(environment); e.stopPropagation();
}} openDeleteDialog(environment);
> }}
<TrashIcon className="h-3 w-3" /> >
</Button> <TrashIcon className="h-3 w-3" />
</Button>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -161,6 +161,7 @@ const addPermissions = z.object({
canCreateServices: z.boolean().optional().default(false), canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false), canDeleteProjects: z.boolean().optional().default(false),
canDeleteServices: z.boolean().optional().default(false), canDeleteServices: z.boolean().optional().default(false),
canDeleteEnvironments: z.boolean().optional().default(false),
canAccessToTraefikFiles: z.boolean().optional().default(false), canAccessToTraefikFiles: z.boolean().optional().default(false),
canAccessToDocker: z.boolean().optional().default(false), canAccessToDocker: z.boolean().optional().default(false),
canAccessToAPI: z.boolean().optional().default(false), canAccessToAPI: z.boolean().optional().default(false),
@@ -196,6 +197,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
accessedProjects: [], accessedProjects: [],
accessedEnvironments: [], accessedEnvironments: [],
accessedServices: [], accessedServices: [],
canDeleteEnvironments: false,
canCreateProjects: false, canCreateProjects: false,
canCreateServices: false, canCreateServices: false,
canDeleteProjects: false, canDeleteProjects: false,
@@ -216,16 +218,17 @@ export const AddUserPermissions = ({ userId }: Props) => {
accessedProjects: data.accessedProjects || [], accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [], accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [], accessedServices: data.accessedServices || [],
canCreateProjects: data.canCreateProjects || false, canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices || false, canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects || false, canDeleteProjects: data.canDeleteProjects,
canDeleteServices: data.canDeleteServices || false, canDeleteServices: data.canDeleteServices,
canAccessToTraefikFiles: data.canAccessToTraefikFiles || false, canDeleteEnvironments: data.canDeleteEnvironments || false,
canAccessToDocker: data.canAccessToDocker || false, canAccessToTraefikFiles: data.canAccessToTraefikFiles,
canAccessToAPI: data.canAccessToAPI || false, canAccessToDocker: data.canAccessToDocker,
canAccessToSSHKeys: data.canAccessToSSHKeys || false, canAccessToAPI: data.canAccessToAPI,
canAccessToGitProviders: data.canAccessToGitProviders || false, canAccessToSSHKeys: data.canAccessToSSHKeys,
canCreateEnvironments: data.canCreateEnvironments || false, canAccessToGitProviders: data.canAccessToGitProviders,
canCreateEnvironments: data.canCreateEnvironments,
}); });
} }
}, [form, form.reset, data, isOpen]); }, [form, form.reset, data, isOpen]);
@@ -237,6 +240,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
canCreateProjects: data.canCreateProjects, canCreateProjects: data.canCreateProjects,
canDeleteServices: data.canDeleteServices, canDeleteServices: data.canDeleteServices,
canDeleteProjects: data.canDeleteProjects, canDeleteProjects: data.canDeleteProjects,
canDeleteEnvironments: data.canDeleteEnvironments,
canAccessToTraefikFiles: data.canAccessToTraefikFiles, canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accessedProjects: data.accessedProjects || [], accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [], accessedEnvironments: data.accessedEnvironments || [],
@@ -379,6 +383,26 @@ export const AddUserPermissions = ({ userId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="canDeleteEnvironments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Environments</FormLabel>
<FormDescription>
Allow the user to delete environments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="canAccessToTraefikFiles" name="canAccessToTraefikFiles"

View File

@@ -0,0 +1 @@
ALTER TABLE "member" ADD COLUMN "canDeleteEnvironments" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -813,6 +813,13 @@
"when": 1759644540829, "when": 1759644540829,
"tag": "0115_serious_black_bird", "tag": "0115_serious_black_bird",
"breakpoints": true "breakpoints": true
},
{
"idx": 116,
"version": "7",
"when": 1759645163834,
"tag": "0116_amusing_firedrake",
"breakpoints": true
} }
] ]
} }

View File

@@ -2,6 +2,7 @@ import {
addNewEnvironment, addNewEnvironment,
checkEnvironmentAccess, checkEnvironmentAccess,
checkEnvironmentCreationPermission, checkEnvironmentCreationPermission,
checkEnvironmentDeletionPermission,
createEnvironment, createEnvironment,
deleteEnvironment, deleteEnvironment,
duplicateEnvironment, duplicateEnvironment,
@@ -194,14 +195,6 @@ export const environmentRouter = createTRPCRouter({
.input(apiRemoveEnvironment) .input(apiRemoveEnvironment)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
try { try {
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
input.environmentId,
ctx.session.activeOrganizationId,
"access",
);
}
const environment = await findEnvironmentById(input.environmentId); const environment = await findEnvironmentById(input.environmentId);
if ( if (
environment.project.organizationId !== environment.project.organizationId !==
@@ -213,27 +206,33 @@ export const environmentRouter = createTRPCRouter({
}); });
} }
// Check environment access for members // Check environment deletion permission
if (ctx.user.role === "member") { await checkEnvironmentDeletionPermission(
const { accessedEnvironments } = await findMemberById( ctx.user.id,
ctx.user.id, environment.projectId,
ctx.session.activeOrganizationId, ctx.session.activeOrganizationId,
); );
if (!accessedEnvironments.includes(environment.environmentId)) { // Additional check for environment access for members
throw new TRPCError({ if (ctx.user.role === "member") {
code: "FORBIDDEN", await checkEnvironmentAccess(
message: "You are not allowed to delete this environment", ctx.user.id,
}); input.environmentId,
} ctx.session.activeOrganizationId,
"access",
);
} }
const deletedEnvironment = await deleteEnvironment(input.environmentId); const deletedEnvironment = await deleteEnvironment(input.environmentId);
return deletedEnvironment; return deletedEnvironment;
} catch (error) { } catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: `Error deleting the environment: ${error instanceof Error ? error.message : error}`, message: `Error deleting the environment: ${error instanceof Error ? error.message : error}`,
cause: error,
}); });
} }
}), }),

View File

@@ -108,6 +108,9 @@ export const member = pgTable("member", {
canAccessToTraefikFiles: boolean("canAccessToTraefikFiles") canAccessToTraefikFiles: boolean("canAccessToTraefikFiles")
.notNull() .notNull()
.default(false), .default(false),
canDeleteEnvironments: boolean("canDeleteEnvironments")
.notNull()
.default(false),
canCreateEnvironments: boolean("canCreateEnvironments") canCreateEnvironments: boolean("canCreateEnvironments")
.notNull() .notNull()
.default(false), .default(false),

View File

@@ -186,6 +186,7 @@ export const apiAssignPermissions = createSchema
canAccessToAPI: z.boolean().optional(), canAccessToAPI: z.boolean().optional(),
canAccessToSSHKeys: z.boolean().optional(), canAccessToSSHKeys: z.boolean().optional(),
canAccessToGitProviders: z.boolean().optional(), canAccessToGitProviders: z.boolean().optional(),
canDeleteEnvironments: z.boolean().optional(),
canCreateEnvironments: z.boolean().optional(), canCreateEnvironments: z.boolean().optional(),
}) })
.required(); .required();

View File

@@ -163,6 +163,24 @@ export const canPerformAccessEnvironment = async (
return false; return false;
}; };
export const canPerformDeleteEnvironment = async (
userId: string,
projectId: string,
organizationId: string,
) => {
const { accessedProjects, canDeleteEnvironments } = await findMemberById(
userId,
organizationId,
);
const haveAccessToProject = accessedProjects.includes(projectId);
if (canDeleteEnvironments && haveAccessToProject) {
return true;
}
return false;
};
export const canAccessToTraefikFiles = async ( export const canAccessToTraefikFiles = async (
userId: string, userId: string,
organizationId: string, organizationId: string,
@@ -240,6 +258,42 @@ export const checkEnvironmentAccess = async (
} }
}; };
export const checkEnvironmentDeletionPermission = async (
userId: string,
projectId: string,
organizationId: string,
) => {
const member = await findMemberById(userId, organizationId);
if (!member) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User not found in organization",
});
}
if (member.role === "owner" || member.role === "admin") {
return true;
}
if (!member.canDeleteEnvironments) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have permission to delete environments",
});
}
const hasProjectAccess = member.accessedProjects.includes(projectId);
if (!hasProjectAccess) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
return true;
};
export const checkProjectAccess = async ( export const checkProjectAccess = async (
authId: string, authId: string,
action: "create" | "delete" | "access", action: "create" | "delete" | "access",