Skip to content

Commit 70a91da

Browse files
authored
Merge pull request #70 from biersoeckli/feat/add-security-context-user-and-group-configuration-for-apps
feat: add security context configuration for apps (user, group, fs). …
2 parents cc4788e + 3606abc commit 70a91da

14 files changed

Lines changed: 174 additions & 26 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- AlterTable
2+
ALTER TABLE "App" ADD COLUMN "securityContextFsGroup" INTEGER;
3+
ALTER TABLE "App" ADD COLUMN "securityContextRunAsGroup" INTEGER;
4+
ALTER TABLE "App" ADD COLUMN "securityContextRunAsUser" INTEGER;

prisma/schema.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ model App {
185185
containerRegistryPassword String?
186186
containerCommand String? // Custom command to override container ENTRYPOINT
187187
containerArgs String? // Custom args to override container CMD (JSON array string)
188+
securityContextRunAsUser Int?
189+
securityContextRunAsGroup Int?
190+
securityContextFsGroup Int?
188191
189192
gitUrl String?
190193
gitBranch String?

src/app/project/app/[appId]/app-breadcrumbs.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@ import { useBreadcrumbs } from "@/frontend/states/zustand.states";
66
import { useEffect } from "react";
77
import { AppExtendedModel } from "@/shared/model/app-extended.model";
88

9-
export default function AppBreadcrumbs({ app }: { app: AppExtendedModel }) {
9+
export default function AppBreadcrumbs({ app, apps, tabName }: { app: AppExtendedModel; apps: { id: string; name: string }[]; tabName?: string }) {
1010
const { setBreadcrumbs } = useBreadcrumbs();
1111
useEffect(() => setBreadcrumbs([
1212
{ name: "Projects", url: "/" },
1313
{ name: app.project.name, url: "/project/" + app.projectId },
14-
{ name: app.name },
14+
{
15+
name: app.name,
16+
dropdownItems: apps.map(a => ({
17+
name: a.name,
18+
url: `/project/app/${a.id}${tabName ? `?tabName=${tabName}` : ''}`,
19+
active: a.id === app.id,
20+
})),
21+
},
1522
]), []);
1623
return <></>;
1724
}

src/app/project/app/[appId]/general/actions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export const saveGeneralAppContainerConfig = async (prevState: any, inputData: A
7272
...existingApp,
7373
containerCommand: validatedData.containerCommand?.trim() || null,
7474
containerArgs: containerArgsJson,
75+
securityContextRunAsUser: validatedData.securityContextRunAsUser ?? null,
76+
securityContextRunAsGroup: validatedData.securityContextRunAsGroup ?? null,
77+
securityContextFsGroup: validatedData.securityContextFsGroup ?? null,
7578
id: appId,
7679
});
7780
});

src/app/project/app/[appId]/general/app-container-config.tsx

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,8 @@ import { toast } from "sonner";
1616
import { AppExtendedModel } from "@/shared/model/app-extended.model";
1717
import { Trash2, Plus } from "lucide-react";
1818
import { z } from "zod";
19-
20-
// Zod schema for the container config form
21-
const appContainerConfigZodModel = z.object({
22-
containerCommand: z.string().trim().nullish(),
23-
containerArgs: z.array(z.object({
24-
value: z.string().trim()
25-
})).optional(),
26-
});
19+
import { appContainerConfigZodModel } from "@/shared/model/app-container-config.model";
20+
import FormLabelWithQuestion from "@/components/custom/form-label-with-question";
2721

2822
export type AppContainerConfigInputModel = z.infer<typeof appContainerConfigZodModel>;
2923

@@ -41,6 +35,9 @@ export default function GeneralAppContainerConfig({ app, readonly }: {
4135
defaultValues: {
4236
containerCommand: app.containerCommand || '',
4337
containerArgs: initialArgs,
38+
securityContextRunAsUser: app.securityContextRunAsUser ?? undefined,
39+
securityContextRunAsGroup: app.securityContextRunAsGroup ?? undefined,
40+
securityContextFsGroup: app.securityContextFsGroup ?? undefined,
4441
},
4542
disabled: readonly,
4643
});
@@ -150,6 +147,80 @@ export default function GeneralAppContainerConfig({ app, readonly }: {
150147
</Button>
151148
)}
152149
</div>
150+
151+
<div className="space-y-4">
152+
<div>
153+
<p className="text-sm font-medium">Security Context (optional)</p>
154+
<p className="text-sm text-muted-foreground mt-1">
155+
Use this when your app requires specific user/group permissions or needs to run with a specific filesystem group for volume access.
156+
</p>
157+
</div>
158+
<div className="grid grid-cols-3 gap-4">
159+
<FormField
160+
control={form.control}
161+
name="securityContextRunAsUser"
162+
render={({ field }) => (
163+
<FormItem>
164+
<FormLabelWithQuestion hint="The UID to run the container process as. Corresponds to runAsUser in the Kubernetes pod securityContext.">
165+
Run As User
166+
</FormLabelWithQuestion>
167+
<FormControl>
168+
<Input
169+
type="number"
170+
placeholder="e.g., 1001"
171+
{...field}
172+
value={field.value ?? ''}
173+
onChange={e => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
174+
/>
175+
</FormControl>
176+
<FormMessage />
177+
</FormItem>
178+
)}
179+
/>
180+
<FormField
181+
control={form.control}
182+
name="securityContextRunAsGroup"
183+
render={({ field }) => (
184+
<FormItem>
185+
<FormLabelWithQuestion hint="The GID to run the container process as. Corresponds to runAsGroup in the Kubernetes pod securityContext.">
186+
Run As Group
187+
</FormLabelWithQuestion>
188+
<FormControl>
189+
<Input
190+
type="number"
191+
placeholder="e.g., 1001"
192+
{...field}
193+
value={field.value ?? ''}
194+
onChange={e => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
195+
/>
196+
</FormControl>
197+
<FormMessage />
198+
</FormItem>
199+
)}
200+
/>
201+
<FormField
202+
control={form.control}
203+
name="securityContextFsGroup"
204+
render={({ field }) => (
205+
<FormItem>
206+
<FormLabelWithQuestion hint="A special supplemental group applied to all containers in the pod. Volume ownership will be set to this GID. Corresponds to fsGroup in the Kubernetes pod securityContext.">
207+
FS Group
208+
</FormLabelWithQuestion>
209+
<FormControl>
210+
<Input
211+
type="number"
212+
placeholder="e.g., 1001"
213+
{...field}
214+
value={field.value ?? ''}
215+
onChange={e => field.onChange(e.target.value === '' ? null : Number(e.target.value))}
216+
/>
217+
</FormControl>
218+
<FormMessage />
219+
</FormItem>
220+
)}
221+
/>
222+
</div>
223+
</div>
153224
</CardContent>
154225
{!readonly && (
155226
<CardFooter className="gap-4">

src/app/project/app/[appId]/page.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ export default async function AppPage({
2020
}
2121
const session = await isAuthorizedReadForApp(appId);
2222
const role = UserGroupUtils.getRolePermissionForApp(session, appId);
23-
const [app, s3Targets, volumeBackups, nodesInfo] = await Promise.all([
24-
appService.getExtendedById(appId),
23+
const app = await appService.getExtendedById(appId);
24+
const [s3Targets, volumeBackups, nodesInfo, apps] = await Promise.all([
2525
s3TargetService.getAll(),
2626
volumeBackupService.getForApp(appId),
27-
clusterService.getNodeInfo()
27+
clusterService.getNodeInfo(),
28+
appService.getAllAppsByProjectID(app.projectId),
2829
]);
2930

3031
return (<>
@@ -35,7 +36,7 @@ export default async function AppPage({
3536
app={app}
3637
nodesInfo={nodesInfo}
3738
tabName={searchParams?.tabName ?? 'overview'} />
38-
<AppBreadcrumbs app={app} />
39+
<AppBreadcrumbs app={app} apps={apps} tabName={searchParams?.tabName} />
3940
</>
4041
)
4142
}

src/components/custom/breadcrumbs-generator.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,23 @@ export function BreadcrumbsGenerator() {
3939
{breadcrumbs.map((x, index) => (<>
4040
{index > 0 && <BreadcrumbSeparator />}
4141
<BreadcrumbItem key={x.name}>
42-
<BreadcrumbLink href={x.url ?? undefined}>{x.name}</BreadcrumbLink>
42+
{x.dropdownItems ? (
43+
<DropdownMenu>
44+
<DropdownMenuTrigger className="flex items-center gap-1 transition-colors hover:text-foreground">
45+
{x.name}
46+
<ChevronDown size={14} />
47+
</DropdownMenuTrigger>
48+
<DropdownMenuContent align="start">
49+
{x.dropdownItems.map((item) => (
50+
<DropdownMenuItem key={item.url} disabled={item.active} asChild={!item.active}>
51+
{item.active ? <span>{item.name}</span> : <Link href={item.url}>{item.name}</Link>}
52+
</DropdownMenuItem>
53+
))}
54+
</DropdownMenuContent>
55+
</DropdownMenu>
56+
) : (
57+
<BreadcrumbLink href={x.url ?? undefined}>{x.name}</BreadcrumbLink>
58+
)}
4359
</BreadcrumbItem>
4460
</>))}
4561
</BreadcrumbList>

src/frontend/states/zustand.states.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,16 @@ interface ZustandBreadcrumbsProps {
4646
setBreadcrumbs: ((result: Breadcrumb[]) => void);
4747
}
4848

49+
export interface BreadcrumbDropdownItem {
50+
name: string;
51+
url: string;
52+
active?: boolean;
53+
}
54+
4955
export interface Breadcrumb {
5056
name: string;
5157
url?: string;
58+
dropdownItems?: BreadcrumbDropdownItem[];
5259
}
5360

5461
export const useBreadcrumbs = create<ZustandBreadcrumbsProps>((set) => ({

src/server/services/deployment.service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@ class DeploymentService {
251251
body.spec!.template!.spec!.imagePullSecrets = [{ name: dockerPullSecretName }];
252252
}
253253

254+
if (app.securityContextRunAsUser != null || app.securityContextRunAsGroup != null || app.securityContextFsGroup != null) {
255+
body.spec!.template!.spec!.securityContext = {
256+
...(app.securityContextRunAsUser != null ? { runAsUser: app.securityContextRunAsUser } : {}),
257+
...(app.securityContextRunAsGroup != null ? { runAsGroup: app.securityContextRunAsGroup } : {}),
258+
...(app.securityContextFsGroup != null ? { fsGroup: app.securityContextFsGroup } : {}),
259+
};
260+
dlog(deploymentId, `Configured Security Context.`);
261+
}
262+
254263
if (existingDeployment) {
255264
dlog(deploymentId, `Replacing existing deployment...`);
256265
const res = await k3s.apps.replaceNamespacedDeployment(app.id, app.projectId, body);
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { stringToNumber, stringToOptionalNumber } from "@/shared/utils/zod.utils";
1+
import { stringToOptionalNumber } from "@/shared/utils/zod.utils";
22
import { z } from "zod";
33

44
export const appContainerConfigZodModel = z.object({
55
containerCommand: z.string().trim().nullish(),
66
containerArgs: z.array(z.object({
77
value: z.string().trim()
88
})).optional(),
9+
securityContextRunAsUser: stringToOptionalNumber,
10+
securityContextRunAsGroup: stringToOptionalNumber,
11+
securityContextFsGroup: stringToOptionalNumber,
912
});
1013

1114
export type AppContainerConfigModel = z.infer<typeof appContainerConfigZodModel>;

0 commit comments

Comments
 (0)