feat: add accessedEnvironments field to user and member schemas, enhancing permission management for environment access

This commit is contained in:
Mauricio Siu
2025-09-05 00:13:04 -06:00
parent 42548f310e
commit 16c37c3ceb
9 changed files with 6960 additions and 100 deletions
@@ -155,6 +155,7 @@ export const extractServices = (data: Environment | undefined) => {
const addPermissions = z.object({ const addPermissions = z.object({
accessedProjects: z.array(z.string()).optional(), accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(), accessedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional().default(false), canCreateProjects: z.boolean().optional().default(false),
canCreateServices: z.boolean().optional().default(false), canCreateServices: z.boolean().optional().default(false),
@@ -200,6 +201,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
if (data) { if (data) {
form.reset({ form.reset({
accessedProjects: data.accessedProjects || [], accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [], accessedServices: data.accessedServices || [],
canCreateProjects: data.canCreateProjects, canCreateProjects: data.canCreateProjects,
canCreateServices: data.canCreateServices, canCreateServices: data.canCreateServices,
@@ -223,6 +225,7 @@ export const AddUserPermissions = ({ userId }: Props) => {
canDeleteProjects: data.canDeleteProjects, canDeleteProjects: data.canDeleteProjects,
canAccessToTraefikFiles: data.canAccessToTraefikFiles, canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accessedProjects: data.accessedProjects || [], accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
accessedServices: data.accessedServices || [], accessedServices: data.accessedServices || [],
canAccessToDocker: data.canAccessToDocker, canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI, canAccessToAPI: data.canAccessToAPI,
@@ -456,91 +459,317 @@ export const AddUserPermissions = ({ userId }: Props) => {
No projects found No projects found
</p> </p>
)} )}
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-1 gap-4">
{projects?.map((item, index) => { {projects?.map((project, projectIndex) => {
const applications = item.environments.flatMap((env) =>
extractServices(env),
);
return ( return (
<FormField <FormField
key={`project-${index}`} key={`project-${projectIndex}`}
control={form.control} control={form.control}
name="accessedProjects" name="accessedProjects"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem <FormItem
key={item.projectId} key={project.projectId}
className="flex flex-col items-start space-x-4 rounded-lg p-4 border" className="flex flex-col items-start rounded-lg p-4 border"
> >
<div className="flex flex-row gap-4"> {/* Project Header */}
<div className="flex flex-row gap-4 items-center w-full">
<FormControl> <FormControl>
<Checkbox <Checkbox
checked={field.value?.includes( checked={field.value?.includes(
item.projectId, project.projectId,
)} )}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
return checked if (checked) {
? field.onChange([ // Add the project
...(field.value || []), field.onChange([
item.projectId, ...(field.value || []),
]) project.projectId,
: field.onChange( ]);
field.value?.filter( } else {
(value) => // Remove the project
value !== item.projectId, field.onChange(
), field.value?.filter(
(value) =>
value !== project.projectId,
),
);
// Also remove all environments and services from this project
const currentEnvs =
form.getValues(
"accessedEnvironments",
) || [];
const currentServices =
form.getValues(
"accessedServices",
) || [];
// Get all environment IDs from this project
const projectEnvIds =
project.environments.map(
(env) => env.environmentId,
); );
// Get all service IDs from this project
const projectServiceIds =
project.environments.flatMap(
(env) =>
extractServices(env).map(
(service) => service.id,
),
);
// Remove environments and services from this project
form.setValue(
"accessedEnvironments",
currentEnvs.filter(
(envId) =>
!projectEnvIds.includes(envId),
),
);
form.setValue(
"accessedServices",
currentServices.filter(
(serviceId) =>
!projectServiceIds.includes(
serviceId,
),
),
);
}
}} }}
/> />
</FormControl> </FormControl>
<FormLabel className="text-sm font-medium text-primary"> <FormLabel className="text-base font-semibold text-primary">
{item.name} {project.name}
</FormLabel> </FormLabel>
</div> </div>
{applications.length === 0 && (
<p className="text-sm text-muted-foreground"> {/* Environments */}
No services found <div className="ml-6 w-full space-y-3">
</p> {project.environments.length === 0 && (
)} <p className="text-sm text-muted-foreground">
{applications?.map((item, index) => ( No environments found
<FormField </p>
key={`project-${index}`} )}
control={form.control} {project.environments.map(
name="accessedServices" (environment, envIndex) => {
render={({ field }) => { const services =
extractServices(environment);
return ( return (
<FormItem <div
key={item.id} key={`env-${envIndex}`}
className="flex flex-row items-start space-x-3 space-y-0" className="border-l-2 border-muted pl-4"
> >
<FormControl> {/* Environment Header with Checkbox */}
<Checkbox <FormField
checked={field.value?.includes( key={`env-${envIndex}`}
item.id, control={form.control}
)} name="accessedEnvironments"
onCheckedChange={(checked) => { render={({ field: envField }) => (
return checked <FormItem className="flex flex-row items-center space-x-3 space-y-0 mb-2">
? field.onChange([ <FormControl>
...(field.value || []), <Checkbox
item.id, checked={envField.value?.includes(
]) environment.environmentId,
: field.onChange( )}
field.value?.filter( onCheckedChange={(
(value) => checked,
value !== item.id, ) => {
), if (checked) {
// Add the environment
envField.onChange([
...(envField.value ||
[]),
environment.environmentId,
]);
// Auto-select the project if not already selected
const currentProjects =
form.getValues(
"accessedProjects",
) || [];
if (
!currentProjects.includes(
project.projectId,
)
) {
form.setValue(
"accessedProjects",
[
...currentProjects,
project.projectId,
],
);
}
} else {
// Remove the environment
envField.onChange(
envField.value?.filter(
(value) =>
value !==
environment.environmentId,
),
);
// Also remove all services from this environment
const currentServices =
form.getValues(
"accessedServices",
) || [];
const environmentServiceIds =
services.map(
(service) =>
service.id,
);
form.setValue(
"accessedServices",
currentServices.filter(
(serviceId) =>
!environmentServiceIds.includes(
serviceId,
),
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full" />
<FormLabel className="text-sm font-medium text-foreground cursor-pointer">
{environment.name}
</FormLabel>
<span className="text-xs text-muted-foreground">
({services.length} services)
</span>
</div>
</FormItem>
)}
/>
{/* Services */}
<div className="ml-4 space-y-2">
{services.length === 0 && (
<p className="text-xs text-muted-foreground">
No services found
</p>
)}
{services.map(
(service, serviceIndex) => (
<FormField
key={`service-${serviceIndex}`}
control={form.control}
name="accessedServices"
render={({
field: serviceField,
}) => {
return (
<FormItem
key={service.id}
className="flex flex-row items-center space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={serviceField.value?.includes(
service.id,
)}
onCheckedChange={(
checked,
) => {
if (checked) {
// Add the service
serviceField.onChange(
[
...(serviceField.value ||
[]),
service.id,
],
);
// Auto-select the environment if not already selected
const currentEnvs =
form.getValues(
"accessedEnvironments",
) || [];
if (
!currentEnvs.includes(
environment.environmentId,
)
) {
form.setValue(
"accessedEnvironments",
[
...currentEnvs,
environment.environmentId,
],
);
}
// Auto-select the project if not already selected
const currentProjects =
form.getValues(
"accessedProjects",
) || [];
if (
!currentProjects.includes(
project.projectId,
)
) {
form.setValue(
"accessedProjects",
[
...currentProjects,
project.projectId,
],
);
}
} else {
// Remove the service
serviceField.onChange(
serviceField.value?.filter(
(value) =>
value !==
service.id,
),
);
}
}}
/>
</FormControl>
<div className="flex items-center gap-2">
<div
className={`w-1.5 h-1.5 rounded-full ${
service.type ===
"application"
? "bg-green-500"
: service.type ===
"compose"
? "bg-purple-500"
: "bg-orange-500"
}`}
/>
<FormLabel className="text-sm text-muted-foreground cursor-pointer">
{service.name}
</FormLabel>
<span className="text-xs text-muted-foreground/70 capitalize">
({service.type})
</span>
</div>
</FormItem>
); );
}} }}
/> />
</FormControl> ),
<FormLabel className="text-sm text-muted-foreground"> )}
{item.name} </div>
</FormLabel> </div>
</FormItem>
); );
}} },
/> )}
))} </div>
</FormItem> </FormItem>
); );
}} }}
@@ -0,0 +1 @@
ALTER TABLE "member" ADD COLUMN "accessedEnvironments" text[] DEFAULT ARRAY[]::text[] NOT NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -764,6 +764,13 @@
"when": 1756955718127, "when": 1756955718127,
"tag": "0108_lazy_next_avengers", "tag": "0108_lazy_next_avengers",
"breakpoints": true "breakpoints": true
},
{
"idx": 109,
"version": "7",
"when": 1757052053574,
"tag": "0109_remarkable_sauron",
"breakpoints": true
} }
] ]
} }
@@ -786,40 +786,43 @@ const EnvironmentPage = (
<ProjectEnvironment projectId={projectId}> <ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button> <Button variant="outline">Project Environment</Button>
</ProjectEnvironment> </ProjectEnvironment>
<DropdownMenu> {auth?.role === "owner" ||
<DropdownMenuTrigger asChild> (auth?.canCreateServices && (
<Button> <DropdownMenu>
<PlusIcon className="h-4 w-4" /> <DropdownMenuTrigger asChild>
Create Service <Button>
</Button> <PlusIcon className="h-4 w-4" />
</DropdownMenuTrigger> Create Service
<DropdownMenuContent </Button>
className="w-[200px] space-y-2" </DropdownMenuTrigger>
align="end" <DropdownMenuContent
> className="w-[200px] space-y-2"
<DropdownMenuLabel className="text-sm font-normal"> align="end"
Actions >
</DropdownMenuLabel> <DropdownMenuLabel className="text-sm font-normal">
<DropdownMenuSeparator /> Actions
<AddApplication </DropdownMenuLabel>
projectName={projectData?.name} <DropdownMenuSeparator />
environmentId={environmentId} <AddApplication
/> projectName={projectData?.name}
<AddDatabase environmentId={environmentId}
projectName={projectData?.name} />
environmentId={environmentId} <AddDatabase
/> projectName={projectData?.name}
<AddCompose environmentId={environmentId}
projectName={projectData?.name} />
environmentId={environmentId} <AddCompose
/> projectName={projectData?.name}
<AddTemplate environmentId={environmentId} /> environmentId={environmentId}
<AddAiAssistant />
projectName={projectData?.name} <AddTemplate environmentId={environmentId} />
environmentId={environmentId} <AddAiAssistant
/> projectName={projectData?.name}
</DropdownMenuContent> environmentId={environmentId}
</DropdownMenu> />
</DropdownMenuContent>
</DropdownMenu>
))}
</div> </div>
</div> </div>
</div> </div>
@@ -4,6 +4,7 @@ import {
duplicateEnvironment, duplicateEnvironment,
findEnvironmentById, findEnvironmentById,
findEnvironmentsByProjectId, findEnvironmentsByProjectId,
findMemberById,
updateEnvironmentById, updateEnvironmentById,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -51,6 +52,48 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment", message: "You are not allowed to access this environment",
}); });
} }
// Check environment access and filter services for members
if (ctx.user.role === "member") {
const { accessedEnvironments, accessedServices } =
await findMemberById(ctx.user.id, ctx.session.activeOrganizationId);
if (!accessedEnvironments.includes(environment.environmentId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to access this environment",
});
}
// Filter services based on member permissions
const filteredEnvironment = {
...environment,
applications: environment.applications.filter((app) =>
accessedServices.includes(app.applicationId),
),
mariadb: environment.mariadb.filter((db) =>
accessedServices.includes(db.mariadbId),
),
mongo: environment.mongo.filter((db) =>
accessedServices.includes(db.mongoId),
),
mysql: environment.mysql.filter((db) =>
accessedServices.includes(db.mysqlId),
),
postgres: environment.postgres.filter((db) =>
accessedServices.includes(db.postgresId),
),
redis: environment.redis.filter((db) =>
accessedServices.includes(db.redisId),
),
compose: environment.compose.filter((comp) =>
accessedServices.includes(comp.composeId),
),
};
return filteredEnvironment;
}
return environment; return environment;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -65,6 +108,8 @@ export const environmentRouter = createTRPCRouter({
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
try { try {
const environments = await findEnvironmentsByProjectId(input.projectId); const environments = await findEnvironmentsByProjectId(input.projectId);
// Check organization access
if ( if (
environments.some( environments.some(
(environment) => (environment) =>
@@ -77,6 +122,22 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment", message: "You are not allowed to access this environment",
}); });
} }
// Filter environments for members based on their permissions
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
// Filter environments to only show those the member has access to
const filteredEnvironments = environments.filter((environment) =>
accessedEnvironments.includes(environment.environmentId),
);
return filteredEnvironments;
}
return environments; return environments;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({
@@ -100,6 +161,22 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment", message: "You are not allowed to access this environment",
}); });
} }
// Check environment access for members
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!accessedEnvironments.includes(environment.environmentId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to delete this environment",
});
}
}
const deletedEnvironment = await deleteEnvironment(input.environmentId); const deletedEnvironment = await deleteEnvironment(input.environmentId);
return deletedEnvironment; return deletedEnvironment;
} catch (error) { } catch (error) {
@@ -125,6 +202,24 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment", message: "You are not allowed to access this environment",
}); });
} }
// Check environment access for members
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (
!accessedEnvironments.includes(currentEnvironment.environmentId)
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to update this environment",
});
}
}
const environment = await updateEnvironmentById( const environment = await updateEnvironmentById(
environmentId, environmentId,
updateData, updateData,
@@ -152,6 +247,22 @@ export const environmentRouter = createTRPCRouter({
message: "You are not allowed to access this environment", message: "You are not allowed to access this environment",
}); });
} }
// Check environment access for members
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (!accessedEnvironments.includes(environment.environmentId)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to duplicate this environment",
});
}
}
const duplicatedEnvironment = await duplicateEnvironment(input); const duplicatedEnvironment = await duplicateEnvironment(input);
return duplicatedEnvironment; return duplicatedEnvironment;
} catch (error) { } catch (error) {
+13 -4
View File
@@ -44,6 +44,7 @@ import {
apiUpdateProject, apiUpdateProject,
applications, applications,
compose, compose,
environments,
mariadb, mariadb,
mongo, mongo,
mysql, mysql,
@@ -178,15 +179,22 @@ export const projectRouter = createTRPCRouter({
}), }),
all: protectedProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role === "member") { if (ctx.user.role === "member") {
const { accessedProjects, accessedServices } = await findMemberById( const { accessedProjects, accessedEnvironments, accessedServices } =
ctx.user.id, await findMemberById(ctx.user.id, ctx.session.activeOrganizationId);
ctx.session.activeOrganizationId,
);
if (accessedProjects.length === 0) { if (accessedProjects.length === 0) {
return []; return [];
} }
// Build environment filter
const environmentFilter =
accessedEnvironments.length === 0
? sql`false`
: sql`${environments.environmentId} IN (${sql.join(
accessedEnvironments.map((envId) => sql`${envId}`),
sql`, `,
)})`;
return await db.query.projects.findMany({ return await db.query.projects.findMany({
where: and( where: and(
sql`${projects.projectId} IN (${sql.join( sql`${projects.projectId} IN (${sql.join(
@@ -197,6 +205,7 @@ export const projectRouter = createTRPCRouter({
), ),
with: { with: {
environments: { environments: {
where: environmentFilter,
with: { with: {
applications: { applications: {
where: buildServiceFilter( where: buildServiceFilter(
+4
View File
@@ -112,6 +112,10 @@ export const member = pgTable("member", {
.array() .array()
.notNull() .notNull()
.default(sql`ARRAY[]::text[]`), .default(sql`ARRAY[]::text[]`),
accessedEnvironments: text("accessedEnvironments")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
accessedServices: text("accesedServices") accessedServices: text("accesedServices")
.array() .array()
.notNull() .notNull()
+1
View File
@@ -175,6 +175,7 @@ export const apiAssignPermissions = createSchema
}) })
.extend({ .extend({
accessedProjects: z.array(z.string()).optional(), accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(), accessedServices: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional(), canCreateProjects: z.boolean().optional(),
canCreateServices: z.boolean().optional(), canCreateServices: z.boolean().optional(),