diff --git a/GLOSSARY.md b/GLOSSARY.md index d9131eb4..a47a3084 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -77,7 +77,7 @@ _Avoid_: advanced configuration, app firewall mode In an App Extended **Full Schema Write**, the App Network Policy Configuration is provided explicitly as either its complete value or `null`; omitting it is invalid. **Agent Network Policy Configuration**: -The active egress network-policy configuration for an **Agent**, consisting of optional Internet access and detailed target-App rules. +The active egress network-policy configuration for an **Agent**, consisting of optional Internet access, optional **Traefik Access**, and detailed target-App rules. _Avoid_: agent firewall, app network-policy configuration In an Agent Extended **Full Schema Write**, the Agent Network Policy Configuration is provided explicitly as either its complete value or `null`; omitting it is invalid. @@ -114,6 +114,10 @@ _Avoid_: policy migration, extended upgrade A configured domain that exposes an **App** through Traefik. _Avoid_: public app address, ingress rule +**Traefik Access**: +The network-policy permission that lets a workload reach other Apps and Agents through the cluster's Traefik ingress, including through their App Domains and Agent Domains. +_Avoid_: Internet access, cluster egress, public access + **Project Network Graph**: A visual representation of the effective traffic routes between the Apps and Agents visible from a **Project**, including Traefik ingress permitted by each **App Domain** even when that App has Network Policies disabled. _Avoid_: network-policy-only graph, deployment topology @@ -410,6 +414,9 @@ _Avoid_: manual initial port requirement - A **Network Policy Mode Migration** replaces an App's active **Simple App Network Policy Configuration** with its equivalent **Extended App Network Policy Configuration**. - An **Extended App Network Policy Configuration** can have zero or more **App Network Policy Rules** for ingress and zero or more for egress. - An **App Domain** permits Traefik ingress to its App when Network Policies are active. +- **Traefik Access** is independent of Internet access. +- A workload with **Traefik Access** enabled can reach another workload through its **App Domain** or Agent Domain even when Internet access is disabled. +- **Traefik Access** only applies while Network Policies are active. - An extended ingress rule identifies an **App Network Policy Rule Source**; an extended egress rule identifies an **App Network Policy Rule Target**. - An **App Network Policy Rule** may reference an App in another project only when its user has a **Workload Permission** for that App. - A **Configured Source** belongs to exactly one **App**. diff --git a/docs/adr/0016-traefik-access-egress.md b/docs/adr/0016-traefik-access-egress.md new file mode 100644 index 00000000..fb9a5286 --- /dev/null +++ b/docs/adr/0016-traefik-access-egress.md @@ -0,0 +1,5 @@ +# Allow Traefik egress as a separate Network Policy flag + +Apps and Agents must be able to reach other workloads through their App and Agent Domains, but the Internet Access egress rule excludes the cluster's private CIDRs. Traffic that reaches a node's public ingress address is translated to a Traefik Pod inside a private CIDR and is therefore denied. We add an independent `allowTraefikAccess` flag to the App and Agent Network Policy Configurations that permits egress to the Traefik Pods in `kube-system`, independent of Internet access. It defaults to `false` to preserve existing isolation. + +The rule targets the Traefik Pods without port restrictions. Reusing Internet access would widen egress to every private cluster destination, while selecting the Traefik Service IP is not expressible as a Network Policy peer. Explicitly allowing ports `80`/`443` would also fail because the k3s Traefik Pods listen on `8000`/`8443`; Network Policy matches the translated Pod port. Restricting the destination to the Traefik Pods keeps the exception narrowly scoped while covering HTTP and HTTPS entrypoints, including custom ones. diff --git a/prisma/migrations/20261110120000_add_allow_traefik_access/migration.sql b/prisma/migrations/20261110120000_add_allow_traefik_access/migration.sql new file mode 100644 index 00000000..38e0a187 --- /dev/null +++ b/prisma/migrations/20261110120000_add_allow_traefik_access/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "AppNetworkPolicy" ADD COLUMN "allowTraefikAccess" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "AgentNetworkPolicy" ADD COLUMN "allowTraefikAccess" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9452d17b..80c07ca0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -319,6 +319,7 @@ model AgentNetworkPolicy { agentId String @unique agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) allowInternetAccess Boolean @default(true) + allowTraefikAccess Boolean @default(false) rules AgentNetworkPolicyRule[] @@ -484,6 +485,7 @@ model AppNetworkPolicy { appId String @unique app App @relation(fields: [appId], references: [id], onDelete: Cascade) allowInternetAccess Boolean @default(true) + allowTraefikAccess Boolean @default(false) rules AppNetworkPolicyRule[] diff --git a/src/__tests__/integration/server/api/v1/subitem-identity.integration.spec.ts b/src/__tests__/integration/server/api/v1/subitem-identity.integration.spec.ts index 8e36545b..d76206b7 100644 --- a/src/__tests__/integration/server/api/v1/subitem-identity.integration.spec.ts +++ b/src/__tests__/integration/server/api/v1/subitem-identity.integration.spec.ts @@ -503,6 +503,7 @@ function createAgentPayload( agentFileMounts: [{ containerMountPath: '/workspace/config.json', content: '{}' }], agentNetworkPolicy: { allowInternetAccess: true, + allowTraefikAccess: false, rules: [{ type: 'EGRESS', targetAppId, port: 443, protocol: 'TCP' }], }, }; diff --git a/src/__tests__/integration/server/services/agent.service.integration.spec.ts b/src/__tests__/integration/server/services/agent.service.integration.spec.ts index e589fa90..7cca9a43 100644 --- a/src/__tests__/integration/server/services/agent.service.integration.spec.ts +++ b/src/__tests__/integration/server/services/agent.service.integration.spec.ts @@ -124,6 +124,7 @@ describe('agent.service integration - saveAgentExtendedModel', () => { agentFileMounts: [{ containerMountPath: '/fresh.txt', content: 'fresh' } as any], agentNetworkPolicy: { allowInternetAccess: false, + allowTraefikAccess: true, rules: [{ targetAppId: targetAppIdB, port: 8443, protocol: 'TCP' }], } as any, }); @@ -153,5 +154,6 @@ describe('agent.service integration - saveAgentExtendedModel', () => { expect(rules[0].id).not.toBe(staleRule.id); expect(refreshedPolicy.allowInternetAccess).toBe(false); + expect(refreshedPolicy.allowTraefikAccess).toBe(true); }); }); diff --git a/src/__tests__/integration/server/services/app.service.integration.spec.ts b/src/__tests__/integration/server/services/app.service.integration.spec.ts index 1947c2b8..d725850b 100644 --- a/src/__tests__/integration/server/services/app.service.integration.spec.ts +++ b/src/__tests__/integration/server/services/app.service.integration.spec.ts @@ -157,6 +157,7 @@ describe('app.service integration - subitem ownership guards', () => { id: sourceApp.id, appNetworkPolicy: { allowInternetAccess: false, + allowTraefikAccess: true, rules: [{ id: rule.id, targetAppId: targetApp.id, @@ -170,6 +171,7 @@ describe('app.service integration - subitem ownership guards', () => { expect(savedApp.appNetworkPolicy).toMatchObject({ allowInternetAccess: false, + allowTraefikAccess: true, rules: [expect.objectContaining({ id: rule.id, port: 8443 })], }); }); @@ -182,6 +184,7 @@ describe('app.service integration - subitem ownership guards', () => { id: sourceApp.id, appNetworkPolicy: { allowInternetAccess: true, + allowTraefikAccess: false, rules: [{ targetAppId: sourceApp.id, targetAgentId: null, @@ -208,6 +211,7 @@ describe('app.service integration - subitem ownership guards', () => { id: sourceApp.id, appNetworkPolicy: { allowInternetAccess: true, + allowTraefikAccess: false, rules: [rule, rule], }, })).rejects.toThrow('A matching network policy rule already exists.'); diff --git a/src/app/project/[projectId]/agents/details/network/agent-network-policy-card.tsx b/src/app/project/[projectId]/agents/details/network/agent-network-policy-card.tsx index 82c8f4da..5fad60a9 100644 --- a/src/app/project/[projectId]/agents/details/network/agent-network-policy-card.tsx +++ b/src/app/project/[projectId]/agents/details/network/agent-network-policy-card.tsx @@ -26,6 +26,7 @@ export default function AgentNetworkPolicyCard({ agent, readonly }: { const { openDialog } = useDialog(); const rules = agent.agentNetworkPolicy?.rules ?? []; const [allowInternetAccess, setAllowInternetAccess] = useState(agent.agentNetworkPolicy?.allowInternetAccess !== false); + const [allowTraefikAccess, setAllowTraefikAccess] = useState(agent.agentNetworkPolicy?.allowTraefikAccess === true); const handleAllowInternetAccessChange = async (checked: boolean) => { setAllowInternetAccess(checked); @@ -40,6 +41,19 @@ export default function AgentNetworkPolicyCard({ agent, readonly }: { } }; + const handleAllowTraefikAccessChange = async (checked: boolean) => { + setAllowTraefikAccess(checked); + try { + await Toast.fromAction(() => saveAgentNetworkPolicySettings( + undefined, + { allowTraefikAccess: checked }, + agent.id, + ), 'Successfully updated Traefik access setting.'); + } catch { + setAllowTraefikAccess(!checked); + } + }; + const asyncDeleteRule = async (ruleId: string) => { const confirm = await openConfirmDialog({ title: "Delete Egress Rule", @@ -70,7 +84,7 @@ export default function AgentNetworkPolicyCard({ agent, readonly }: { Network Policy - Control whether this agent has general internet access and define specific egress rules to other apps. + Control whether this agent has general internet and Traefik access, and define specific egress rules to other apps. @@ -89,6 +103,21 @@ export default function AgentNetworkPolicyCard({ agent, readonly }: { /> +
+
+ +

+ Control whether this agent can reach apps and agents exposed through this cluster's Traefik ingress. +

+
+ +
+ {rules.length} Egress Rules diff --git a/src/app/project/[projectId]/apps/details/network/policy/app-network-policy-rule-section.tsx b/src/app/project/[projectId]/apps/details/network/policy/app-network-policy-rule-section.tsx index a610526c..7f8393c6 100644 --- a/src/app/project/[projectId]/apps/details/network/policy/app-network-policy-rule-section.tsx +++ b/src/app/project/[projectId]/apps/details/network/policy/app-network-policy-rule-section.tsx @@ -37,6 +37,8 @@ type AppNetworkPolicyRuleSectionProps = { projects: NetworkPolicyTargetProject[]; internetAccess?: boolean; onInternetAccessChange?: (checked: boolean) => void; + traefikAccess?: boolean; + onTraefikAccessChange?: (checked: boolean) => void; }; export default function AppNetworkPolicyRuleSection({ @@ -49,6 +51,8 @@ export default function AppNetworkPolicyRuleSection({ projects, internetAccess, onInternetAccessChange, + traefikAccess, + onTraefikAccessChange, }: AppNetworkPolicyRuleSectionProps) { const ingress = direction === 'INGRESS'; const title = ingress ? 'Ingress rules' : 'Egress rules'; @@ -84,6 +88,22 @@ export default function AppNetworkPolicyRuleSection({ )} + {!ingress && traefikAccess !== undefined && onTraefikAccessChange && ( + + +
+ +

Allow outgoing connections to apps and agents exposed through this cluster's Traefik ingress.

+
+ +
+
+ )} +
diff --git a/src/app/project/[projectId]/apps/details/network/policy/network-policy.tsx b/src/app/project/[projectId]/apps/details/network/policy/network-policy.tsx index 84aa710e..193bfd6e 100644 --- a/src/app/project/[projectId]/apps/details/network/policy/network-policy.tsx +++ b/src/app/project/[projectId]/apps/details/network/policy/network-policy.tsx @@ -85,6 +85,10 @@ export default function NetworkPolicy({ app, readonly }: { app: AppExtendedModel setDraft(current => ({ ...current, allowInternetAccess: nextInternet })); }; + const changeTraefikAccess = (nextTraefik: boolean) => { + setDraft(current => ({ ...current, allowTraefikAccess: nextTraefik })); + }; + const openRuleDialog = (direction: AppNetworkPolicyDirection) => openDialog( rule.type === 'INGRESS')} readonly={readonly} onAdd={() => openRuleDialog('INGRESS')} onDeleteRule={deleteRule} currentProjectId={app.project.id} projects={projects} /> - rule.type === 'EGRESS')} readonly={readonly} onAdd={() => openRuleDialog('EGRESS')} onDeleteRule={deleteRule} currentProjectId={app.project.id} projects={projects} internetAccess={draft.allowInternetAccess} onInternetAccessChange={changeInternetAccess} /> + rule.type === 'EGRESS')} readonly={readonly} onAdd={() => openRuleDialog('EGRESS')} onDeleteRule={deleteRule} currentProjectId={app.project.id} projects={projects} internetAccess={draft.allowInternetAccess} onInternetAccessChange={changeInternetAccess} traefikAccess={draft.allowTraefikAccess} onTraefikAccessChange={changeTraefikAccess} /> `${domain.hostname}:${domain.port}`)} projects={projects} /> diff --git a/src/app/project/[projectId]/overview/network-graph/context-menus/project-network-graph-agent-context-menu.tsx b/src/app/project/[projectId]/overview/network-graph/context-menus/project-network-graph-agent-context-menu.tsx index 8621a278..9e43e552 100644 --- a/src/app/project/[projectId]/overview/network-graph/context-menus/project-network-graph-agent-context-menu.tsx +++ b/src/app/project/[projectId]/overview/network-graph/context-menus/project-network-graph-agent-context-menu.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react'; import { useEffect, useState } from 'react'; -import { Bot, Globe2, Settings, Trash2 } from 'lucide-react'; +import { Bot, Globe2, Network, Settings, Trash2 } from 'lucide-react'; import { ContextMenu, ContextMenuContent, @@ -28,12 +28,18 @@ export function ProjectNetworkGraphAgentContextMenu({ children, }: ProjectNetworkGraphAgentContextMenuProps) { const internetAccessEnabled = agent.agentNetworkPolicy?.allowInternetAccess !== false; + const traefikAccessEnabled = agent.agentNetworkPolicy?.allowTraefikAccess === true; const [allowsInternetAccess, setAllowsInternetAccess] = useState(internetAccessEnabled); + const [allowsTraefikAccess, setAllowsTraefikAccess] = useState(traefikAccessEnabled); useEffect(() => { setAllowsInternetAccess(internetAccessEnabled); }, [internetAccessEnabled]); + useEffect(() => { + setAllowsTraefikAccess(traefikAccessEnabled); + }, [traefikAccessEnabled]); + const toggleInternetAccess = async () => { const nextAllowsInternetAccess = !allowsInternetAccess; setAllowsInternetAccess(nextAllowsInternetAccess); @@ -51,6 +57,23 @@ export function ProjectNetworkGraphAgentContextMenu({ } }; + const toggleTraefikAccess = async () => { + const nextAllowsTraefikAccess = !allowsTraefikAccess; + setAllowsTraefikAccess(nextAllowsTraefikAccess); + try { + await Toast.fromAction( + () => saveAgentNetworkPolicySettings( + undefined, + { allowTraefikAccess: nextAllowsTraefikAccess }, + agent.id, + ), + `Egress Traefik access ${nextAllowsTraefikAccess ? 'enabled' : 'disabled'}.`, + ); + } catch { + setAllowsTraefikAccess(!nextAllowsTraefikAccess); + } + }; + return ( {children} @@ -70,6 +93,12 @@ export function ProjectNetworkGraphAgentContextMenu({ {allowsInternetAccess ? 'Disable' : 'Enable'} Egress Internet Access + void toggleTraefikAccess()} + > + + {allowsTraefikAccess ? 'Disable' : 'Enable'} Egress Traefik Access + diff --git a/src/app/project/[projectId]/overview/network-graph/context-menus/project-network-graph-app-context-menu.tsx b/src/app/project/[projectId]/overview/network-graph/context-menus/project-network-graph-app-context-menu.tsx index a0dbc2e3..58344e7a 100644 --- a/src/app/project/[projectId]/overview/network-graph/context-menus/project-network-graph-app-context-menu.tsx +++ b/src/app/project/[projectId]/overview/network-graph/context-menus/project-network-graph-app-context-menu.tsx @@ -1,7 +1,7 @@ 'use client'; import type { ReactNode } from 'react'; -import { Box, Globe2, Hammer, Logs, Play, Rocket, RotateCwClock, Settings, Square, Trash2, Zap } from 'lucide-react'; +import { Box, Globe2, Hammer, Logs, Network, Play, Rocket, RotateCwClock, Settings, Square, Trash2, Zap } from 'lucide-react'; import { ContextMenu, ContextMenuContent, @@ -24,6 +24,8 @@ export type ProjectNetworkGraphAppContextMenuProps = { role?: RolePermissionEnum; allowInternetAccess: boolean; onToggleInternetAccess: () => void; + allowTraefikAccess: boolean; + onToggleTraefikAccess: () => void; onOpenDrawerTab: (tab: 'deployments' | 'logs' | 'backups' | 'settings') => void; onShowEnvironment: () => void; onDelete: () => void; @@ -35,6 +37,8 @@ export function ProjectNetworkGraphAppContextMenu({ role, allowInternetAccess, onToggleInternetAccess, + allowTraefikAccess, + onToggleTraefikAccess, onDelete, children, onOpenDrawerTab, @@ -114,6 +118,10 @@ export function ProjectNetworkGraphAppContextMenu({ {allowInternetAccess ? 'Disable' : 'Enable'} Egress Internet Access + + + {allowTraefikAccess ? 'Disable' : 'Enable'} Egress Traefik Access + } {lifecycle.canManage && diff --git a/src/app/project/[projectId]/overview/network-graph/project-network-graph.tsx b/src/app/project/[projectId]/overview/network-graph/project-network-graph.tsx index 46b47088..df4d18bc 100644 --- a/src/app/project/[projectId]/overview/network-graph/project-network-graph.tsx +++ b/src/app/project/[projectId]/overview/network-graph/project-network-graph.tsx @@ -251,6 +251,12 @@ function ProjectNetworkGraphEditor({ allowInternetAccess: !draft.allowInternetAccess, })); }, [updateDraft]); + const toggleTraefikAccess = useCallback((appId: string) => { + updateDraft(appId, draft => ({ + ...draft, + allowTraefikAccess: !draft.allowTraefikAccess, + })); + }, [updateDraft]); const saveChanges = async () => { const changed = Object.values(drafts).filter(draft => !AppNetworkPolicyDraftUtils.equals(draft, baseline[draft.appId])); if (!changed.length) { return; } @@ -310,6 +316,8 @@ function ProjectNetworkGraphEditor({ role: appRole, allowInternetAccess: draft.allowInternetAccess, onToggleInternetAccess: () => toggleInternetAccess(app.id), + allowTraefikAccess: draft.allowTraefikAccess, + onToggleTraefikAccess: () => toggleTraefikAccess(app.id), onOpenDrawerTab: (tab: DrawerTab) => { drawerSession.openAppTab(app.id, tab); }, @@ -330,7 +338,7 @@ function ProjectNetworkGraphEditor({ ), }, }; - }), [agents, apps, connectionSourceNodeId, connectionTargetNodeId, deleteLocalAgent, deleteLocalApp, drafts, drawerSession, layout?.edges, layout?.nodes, selectedNodeId, session, toggleInternetAccess]); + }), [agents, apps, connectionSourceNodeId, connectionTargetNodeId, deleteLocalAgent, deleteLocalApp, drafts, drawerSession, layout?.edges, layout?.nodes, selectedNodeId, session, toggleInternetAccess, toggleTraefikAccess]); const [nodes, setNodes, onNodesChange] = useNodesState(projectedNodes); useEffect(() => setNodes(projectedNodes), [projectedNodes, setNodes]); diff --git a/src/server/api/v1/agent/route.ts b/src/server/api/v1/agent/route.ts index 2a23c0da..9ac94988 100644 --- a/src/server/api/v1/agent/route.ts +++ b/src/server/api/v1/agent/route.ts @@ -103,7 +103,7 @@ export const agentRoutes = new Elysia() summary: 'Create or update agent.', description: 'Creates an agent template when id is omitted, otherwise replaces the existing template. projectId cannot be changed. Call deployAgent afterwards to apply it.', 'x-mcp-description': `Upsert one agent template. -- Omit id to create: projectId, llmGatewayId and modelAlias (non-empty array) are required. agentDomains, agentVolumes, agentFileMounts are required arrays, and agentNetworkPolicy must be null or an object. +- Omit id to create: projectId, llmGatewayId and modelAlias (non-empty array) are required. agentDomains, agentVolumes, agentFileMounts are required arrays, and agentNetworkPolicy must be null or an object. agentNetworkPolicy.allowTraefikAccess is optional and defaults to false. - Send id to update: projectId must equal the existing agent's projectId and cannot be changed. The request replaces the stored agent, so omitted or empty sub-resource arrays delete the existing entries. Call getAgent first, then send the full object back with the ids of sub-resources you want to keep. - When MCP secret redaction is enabled, getAgent omits gitToken and containerRegistryPassword. Obtain existing values from a secure source and include them when updating; do not substitute empty strings, which can overwrite secrets. If the values are unavailable, do not update the agent. - This only saves the template. Call deployAgent to apply it, then startAgentSandbox to run a sandbox. Saving revalidates caches but does not create Kubernetes resources. @@ -112,7 +112,7 @@ Example create: {"name":"coder","projectId":"","llmGatewayId":"","modelAlias":["gpt-4o"],"sourceType":"CONTAINER","buildMethod":"DOCKERFILE","containerImageSource":"ghcr.io/example/agent:latest","dockerfilePath":"./Dockerfile","warmPoolReplicas":0,"deployFileBrowser":false,"healthCheckPeriodSeconds":15,"healthCheckTimeoutSeconds":5,"healthCheckFailureThreshold":3,"agentDomains":[],"agentVolumes":[],"agentFileMounts":[],"agentNetworkPolicy":null} Example update (read getAgent first and keep all sub-resource ids you do not want to delete): -{"id":"","name":"coder","projectId":"","llmGatewayId":"","modelAlias":["gpt-4o","gpt-4o-mini"],"sourceType":"CONTAINER","buildMethod":"DOCKERFILE","containerImageSource":"ghcr.io/example/agent:latest","dockerfilePath":"./Dockerfile","warmPoolReplicas":1,"deployFileBrowser":false,"healthCheckPeriodSeconds":15,"healthCheckTimeoutSeconds":5,"healthCheckFailureThreshold":3,"agentDomains":[],"agentVolumes":[{"id":"","containerMountPath":"/workspace","size":10,"storageClassName":"longhorn","volumeType":"PER_SANDBOX"}],"agentFileMounts":[],"agentNetworkPolicy":{"allowInternetAccess":true,"rules":[]}}`, +{"id":"","name":"coder","projectId":"","llmGatewayId":"","modelAlias":["gpt-4o","gpt-4o-mini"],"sourceType":"CONTAINER","buildMethod":"DOCKERFILE","containerImageSource":"ghcr.io/example/agent:latest","dockerfilePath":"./Dockerfile","warmPoolReplicas":1,"deployFileBrowser":false,"healthCheckPeriodSeconds":15,"healthCheckTimeoutSeconds":5,"healthCheckFailureThreshold":3,"agentDomains":[],"agentVolumes":[{"id":"","containerMountPath":"/workspace","size":10,"storageClassName":"longhorn","volumeType":"PER_SANDBOX"}],"agentFileMounts":[],"agentNetworkPolicy":{"allowInternetAccess":true,"allowTraefikAccess":false,"rules":[]}}`, operationId: 'saveAgent', tags: ['Agents'], security: [{ bearerAuth: [] }] diff --git a/src/server/api/v1/app/route.ts b/src/server/api/v1/app/route.ts index da2e8f67..05419c7f 100644 --- a/src/server/api/v1/app/route.ts +++ b/src/server/api/v1/app/route.ts @@ -166,7 +166,7 @@ Returns stdout, stderr and exitCode. The command is executed inside the live Pod summary: 'Create or update app.', description: 'Creates an app when id is omitted, otherwise replaces the existing app. projectId cannot be changed. Call deployApp afterwards to roll out the desired state.', 'x-mcp-description': `Upsert one App. -- Omit id to create: projectId is required. appDomains, appNodePorts, appFileMounts, appVolumes and appBasicAuths are required arrays (empty allowed), and appNetworkPolicy must be null or an object. +- Omit id to create: projectId is required. appDomains, appNodePorts, appFileMounts, appVolumes and appBasicAuths are required arrays (empty allowed), and appNetworkPolicy must be null or an object. appNetworkPolicy.allowTraefikAccess is optional and defaults to false. - Send id to update: projectId must equal the existing app's projectId and cannot be changed. The request replaces the stored app, so omitted or empty sub-resource arrays delete the existing entries. Call getApp first, then send the full object back with the ids of sub-resources you want to keep. - When MCP secret redaction is enabled, getApp omits envVars, buildArgs, gitToken, containerRegistryPassword and appBasicAuths[].password. Obtain the existing values from a secure source and include them when updating; do not substitute empty strings, which can overwrite secrets. If the values are unavailable, do not update the app. - This only saves desired state. Call deployApp afterwards to roll it out. Saving revalidates server caches but does not change Kubernetes resources. diff --git a/src/server/services/agent-network-policy.service.ts b/src/server/services/agent-network-policy.service.ts index 31bfc67f..7556006a 100644 --- a/src/server/services/agent-network-policy.service.ts +++ b/src/server/services/agent-network-policy.service.ts @@ -50,7 +50,10 @@ class AgentNetworkPolicyService { projectId = pid; await db.agentNetworkPolicy.update({ where: { id: policy.id }, - data: { allowInternetAccess: input.allowInternetAccess }, + data: { + ...(input.allowInternetAccess !== undefined && { allowInternetAccess: input.allowInternetAccess }), + ...(input.allowTraefikAccess !== undefined && { allowTraefikAccess: input.allowTraefikAccess }), + }, }); } finally { if (!tx) { @@ -146,10 +149,17 @@ class AgentNetworkPolicyService { const policy = existingPolicy ? await db.agentNetworkPolicy.update({ where: { id: existingPolicy.id }, - data: { allowInternetAccess: input.allowInternetAccess }, + data: { + allowInternetAccess: input.allowInternetAccess, + allowTraefikAccess: input.allowTraefikAccess, + }, }) : await db.agentNetworkPolicy.create({ - data: { agentId, allowInternetAccess: input.allowInternetAccess }, + data: { + agentId, + allowInternetAccess: input.allowInternetAccess, + allowTraefikAccess: input.allowTraefikAccess, + }, }); await this.replaceEgressRules(db, policy.id, input.rules.map(rule => ({ diff --git a/src/server/services/agent-network-policy.service.unit.spec.ts b/src/server/services/agent-network-policy.service.unit.spec.ts index f6dc5300..42edadba 100644 --- a/src/server/services/agent-network-policy.service.unit.spec.ts +++ b/src/server/services/agent-network-policy.service.unit.spec.ts @@ -59,6 +59,44 @@ describe('agent-network-policy.service', () => { Promise.resolve(where.id.in.map(id => ({ id, projectId: `project-${id}` })))); }); + it('updates only the provided network policy setting', async () => { + dbMocks.client.agent.findFirstOrThrow.mockResolvedValue({ id: 'agent-1', projectId: 'project-1' }); + dbMocks.client.agentNetworkPolicy.findUnique.mockResolvedValue({ + id: 'policy-1', + agentId: 'agent-1', + allowInternetAccess: true, + allowTraefikAccess: false, + }); + + await agentNetworkPolicyService.saveSettings({ agentId: 'agent-1', allowTraefikAccess: true }); + + expect(dbMocks.client.agentNetworkPolicy.update).toHaveBeenCalledWith({ + where: { id: 'policy-1' }, + data: { allowTraefikAccess: true }, + }); + }); + + it('updates both network policy settings when both are provided', async () => { + dbMocks.client.agent.findFirstOrThrow.mockResolvedValue({ id: 'agent-1', projectId: 'project-1' }); + dbMocks.client.agentNetworkPolicy.findUnique.mockResolvedValue({ + id: 'policy-1', + agentId: 'agent-1', + allowInternetAccess: true, + allowTraefikAccess: false, + }); + + await agentNetworkPolicyService.saveSettings({ + agentId: 'agent-1', + allowInternetAccess: false, + allowTraefikAccess: true, + }); + + expect(dbMocks.client.agentNetworkPolicy.update).toHaveBeenCalledWith({ + where: { id: 'policy-1' }, + data: { allowInternetAccess: false, allowTraefikAccess: true }, + }); + }); + it('removes the mirrored INGRESS counterpart when an egress rule is deleted', async () => { dbMocks.client.agentNetworkPolicyRule.findUnique.mockResolvedValue({ id: 'rule-1', diff --git a/src/server/services/agent-template.service.ts b/src/server/services/agent-template.service.ts index c6c61072..a30c04eb 100644 --- a/src/server/services/agent-template.service.ts +++ b/src/server/services/agent-template.service.ts @@ -61,6 +61,7 @@ class AgentTemplateService { ): NonNullable { return { allowInternetAccess: true, + allowTraefikAccess: false, rules: [{ type: 'EGRESS', targetAppId, diff --git a/src/server/services/agent-template.service.unit.spec.ts b/src/server/services/agent-template.service.unit.spec.ts index 5f9c3c27..2d972a18 100644 --- a/src/server/services/agent-template.service.unit.spec.ts +++ b/src/server/services/agent-template.service.unit.spec.ts @@ -186,7 +186,7 @@ describe('agent-template.service', () => { await agentTemplateService.createAgentFromTemplate('project-1', template); - expect(dbAgentNetworkPolicyMocks.create).toHaveBeenCalledWith({ data: { agentId: 'agent-opencode', allowInternetAccess: true } }); + expect(dbAgentNetworkPolicyMocks.create).toHaveBeenCalledWith({ data: { agentId: 'agent-opencode', allowInternetAccess: true, allowTraefikAccess: false } }); expect(dbAgentNetworkPolicyRuleMocks.create).toHaveBeenCalledWith({ data: { agentNetworkPolicyId: 'policy-1', type: 'EGRESS', targetAppId: 'litellm-app', port: 4000, protocol: 'TCP' } }); diff --git a/src/server/services/app-network-policy.service.ts b/src/server/services/app-network-policy.service.ts index 5184bd08..f1fcbf23 100644 --- a/src/server/services/app-network-policy.service.ts +++ b/src/server/services/app-network-policy.service.ts @@ -169,10 +169,17 @@ class AppNetworkPolicyService { const policy = existingPolicy ? await db.appNetworkPolicy.update({ where: { id: existingPolicy.id }, - data: { allowInternetAccess: input.allowInternetAccess }, + data: { + allowInternetAccess: input.allowInternetAccess, + allowTraefikAccess: input.allowTraefikAccess, + }, }) : await db.appNetworkPolicy.create({ - data: { appId, allowInternetAccess: input.allowInternetAccess }, + data: { + appId, + allowInternetAccess: input.allowInternetAccess, + allowTraefikAccess: input.allowTraefikAccess, + }, }); await this.replaceRules(db, appId, policy.id, input.rules.map(rule => ({ @@ -189,6 +196,7 @@ class AppNetworkPolicyService { appId: string; useNetworkPolicy: boolean; allowInternetAccess: boolean; + allowTraefikAccess: boolean; rules: AppNetworkPolicyRuleEditModel[]; }, session?: UserSession): Promise { const { app, counterpartAgents, counterpartApps, touches } = await dataAccess.client.$transaction(async (db) => { @@ -201,7 +209,10 @@ class AppNetworkPolicyService { }); await db.appNetworkPolicy.update({ where: { id: policy.id }, - data: { allowInternetAccess: input.allowInternetAccess }, + data: { + allowInternetAccess: input.allowInternetAccess, + allowTraefikAccess: input.allowTraefikAccess, + }, }); // Snapshot the current rules before replaceRules deletes the ones diff --git a/src/server/services/app-network-policy.service.unit.spec.ts b/src/server/services/app-network-policy.service.unit.spec.ts index 4ea697f8..d2e3081b 100644 --- a/src/server/services/app-network-policy.service.unit.spec.ts +++ b/src/server/services/app-network-policy.service.unit.spec.ts @@ -85,6 +85,7 @@ describe('app-network-policy.service', () => { appId: 'app-1', useNetworkPolicy: false, allowInternetAccess: true, + allowTraefikAccess: false, rules: [ { type: 'INGRESS', targetType: 'APP', targetId: 'app-2', port: 8080, protocol: 'TCP' }, { id: 'rule-keep', type: 'EGRESS', targetType: 'APP', targetId: 'app-3', port: 443, protocol: 'TCP' }, @@ -102,7 +103,7 @@ describe('app-network-policy.service', () => { }); expect(dbMocks.client.appNetworkPolicy.update).toHaveBeenCalledWith({ where: { id: 'policy-1' }, - data: { allowInternetAccess: true }, + data: { allowInternetAccess: true, allowTraefikAccess: false }, }); expect(dbMocks.client.appNetworkPolicyRule.create).toHaveBeenCalledWith({ @@ -138,6 +139,21 @@ describe('app-network-policy.service', () => { expect(revalidateTag).toHaveBeenCalledWith('apps-project-app-3'); }); + it('persists the Traefik access setting', async () => { + await appNetworkPolicyService.savePolicyConfiguration({ + appId: 'app-1', + useNetworkPolicy: true, + allowInternetAccess: false, + allowTraefikAccess: true, + rules: [], + }); + + expect(dbMocks.client.appNetworkPolicy.update).toHaveBeenCalledWith({ + where: { id: 'policy-1' }, + data: { allowInternetAccess: false, allowTraefikAccess: true }, + }); + }); + it('removes mirrored counterparts when a rule is dropped from a saved rule set', async () => { dbMocks.client.appNetworkPolicyRule.findMany.mockResolvedValue([ { type: 'EGRESS', targetAppId: 'app-3', targetAgentId: null, port: 443, protocol: 'TCP' }, @@ -149,6 +165,7 @@ describe('app-network-policy.service', () => { appId: 'app-1', useNetworkPolicy: true, allowInternetAccess: true, + allowTraefikAccess: false, rules: [ { id: 'rule-keep', type: 'INGRESS', targetType: 'APP', targetId: 'app-2', port: 8080, protocol: 'TCP' }, ], @@ -173,6 +190,7 @@ describe('app-network-policy.service', () => { appId: 'app-1', useNetworkPolicy: true, allowInternetAccess: true, + allowTraefikAccess: false, rules: [{ type: 'INGRESS', targetType: 'AGENT', targetId: 'agent-2', port: 8080, protocol: 'TCP' }], }); @@ -185,6 +203,7 @@ describe('app-network-policy.service', () => { appId: 'app-1', useNetworkPolicy: true, allowInternetAccess: true, + allowTraefikAccess: false, rules: [ { type: 'EGRESS', targetType: 'APP', targetId: 'app-1', port: 80, protocol: 'TCP' }, ], diff --git a/src/server/services/network-policy.service.ts b/src/server/services/network-policy.service.ts index 3210e68e..80f7b012 100644 --- a/src/server/services/network-policy.service.ts +++ b/src/server/services/network-policy.service.ts @@ -10,6 +10,7 @@ import { QS_AUTH_PROXY_SERVICE_NAME } from "./qs-auth-proxy.service"; export type AgentSandboxTemplateNetworkPolicyConfig = { allowInternetAccess: boolean; + allowTraefikAccess: boolean; rules: AgentNetworkPolicyRuleWithTargetAppModel[]; } | null; @@ -60,7 +61,12 @@ class NetworkPolicyService { }, policyTypes: ["Ingress", "Egress"], ingress: this.getExtendedIngressRules(app.id, app.appNetworkPolicy?.rules ?? [], (app.appDomains?.length ?? 0) > 0, app.appNodePorts), - egress: this.getExtendedEgressRules(app.id, app.appNetworkPolicy?.rules ?? [], app.appNetworkPolicy?.allowInternetAccess !== false) + egress: this.getExtendedEgressRules( + app.id, + app.appNetworkPolicy?.rules ?? [], + app.appNetworkPolicy?.allowInternetAccess !== false, + app.appNetworkPolicy?.allowTraefikAccess === true, + ) } }; await this.applyNetworkPolicy(namespace, policyName, policy); @@ -129,10 +135,16 @@ class NetworkPolicyService { return [...result, ...this.getNodePortIngressRules(nodePorts)]; } - private getExtendedEgressRules(appId: string, rules: AppNetworkPolicyRuleWithTargetModel[], allowInternetAccess: boolean): V1NetworkPolicyEgressRule[] { + private getExtendedEgressRules( + appId: string, + rules: AppNetworkPolicyRuleWithTargetModel[], + allowInternetAccess: boolean, + allowTraefikAccess: boolean, + ): V1NetworkPolicyEgressRule[] { // Traffic between replicas of the same App is always allowed on all ports. const result: V1NetworkPolicyEgressRule[] = [{ to: [this.getSelfPeer(appId)] }, this.getDnsEgressRule()]; if (allowInternetAccess) result.push(this.getInternetEgressRule()); + if (allowTraefikAccess) result.push(this.getTraefikEgressRule()); for (const rule of rules.filter(rule => rule.type === 'EGRESS')) result.push(this.getTargetEgressRule(rule)); return result; } @@ -176,6 +188,15 @@ class NetworkPolicyService { }; } + private getTraefikEgressRule(): V1NetworkPolicyEgressRule { + return { + to: [{ + namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' } }, + podSelector: { matchLabels: { 'app.kubernetes.io/name': 'traefik' } }, + }], + }; + } + private getNodePortIngressRules(nodePorts: { port: number; protocol?: string }[]): V1NetworkPolicyIngressRule[] { if (!nodePorts.length) return []; return [{ _from: [{ ipBlock: { cidr: '0.0.0.0/0' } }], ports: nodePorts.map(nodePort => ({ protocol: (nodePort.protocol || 'TCP'), port: nodePort.port })) }]; @@ -198,6 +219,10 @@ class NetworkPolicyService { egress.push(this.getInternetEgressRule()); } + if (agentNetworkPolicy.allowTraefikAccess) { + egress.push(this.getTraefikEgressRule()); + } + const seen = new Set(); for (const rule of agentNetworkPolicy.rules) { if (rule.type && rule.type !== 'EGRESS') { diff --git a/src/server/services/network-policy.service.unit.spec.ts b/src/server/services/network-policy.service.unit.spec.ts index 2382df84..a0194346 100644 --- a/src/server/services/network-policy.service.unit.spec.ts +++ b/src/server/services/network-policy.service.unit.spec.ts @@ -19,6 +19,15 @@ vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ import networkPolicyService from './network-policy.service'; import { AppExtendedModel } from '@/shared/model/app-extended.model'; +function traefikEgressRule() { + return { + to: [{ + namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' } }, + podSelector: { matchLabels: { 'app.kubernetes.io/name': 'traefik' } }, + }], + }; +} + describe('network-policy.service', () => { beforeEach(() => { vi.clearAllMocks(); @@ -60,6 +69,7 @@ describe('network-policy.service', () => { it('allows auth proxy ingress from the quickstack namespace to all sandbox ports', () => { const policy = networkPolicyService.buildAgentSandboxTemplateNetworkPolicy({ allowInternetAccess: false, + allowTraefikAccess: false, rules: [], }); @@ -82,6 +92,7 @@ describe('network-policy.service', () => { it('allows QuickStack ingress to all sandbox ports', () => { const policy = networkPolicyService.buildAgentSandboxTemplateNetworkPolicy({ allowInternetAccess: false, + allowTraefikAccess: false, rules: [], }); @@ -158,6 +169,66 @@ describe('network-policy.service', () => { })])); }); + it('allows egress to Traefik for an App when Traefik access is enabled', async () => { + const app = { + id: 'demo-app', + projectId: 'app-project', + useNetworkPolicy: true, + appDomains: [], + appNodePorts: [], + appNetworkPolicy: { + allowInternetAccess: false, + allowTraefikAccess: true, + rules: [], + }, + } as unknown as AppExtendedModel; + + await networkPolicyService.reconcileNetworkPolicy(app); + + const policy = k3sMocks.createNamespacedNetworkPolicy.mock.calls[0][0].body; + expect(policy.spec.egress).toEqual(expect.arrayContaining([traefikEgressRule()])); + }); + + it('does not allow egress to Traefik for an App when Traefik access is disabled', async () => { + const app = { + id: 'demo-app', + projectId: 'app-project', + useNetworkPolicy: true, + appDomains: [], + appNodePorts: [], + appNetworkPolicy: { + allowInternetAccess: true, + allowTraefikAccess: false, + rules: [], + }, + } as unknown as AppExtendedModel; + + await networkPolicyService.reconcileNetworkPolicy(app); + + const policy = k3sMocks.createNamespacedNetworkPolicy.mock.calls[0][0].body; + expect(policy.spec.egress).not.toEqual(expect.arrayContaining([traefikEgressRule()])); + }); + + it('allows egress to Traefik for an Agent sandbox when Traefik access is enabled', () => { + const policy = networkPolicyService.buildAgentSandboxTemplateNetworkPolicy({ + allowInternetAccess: false, + allowTraefikAccess: true, + rules: [], + }); + + expect(policy?.egress).toEqual(expect.arrayContaining([traefikEgressRule()])); + }); + + it('does not allow egress to Traefik for an Agent sandbox when Traefik access is disabled', () => { + const policy = networkPolicyService.buildAgentSandboxTemplateNetworkPolicy({ + allowInternetAccess: true, + allowTraefikAccess: false, + rules: [], + }); + + expect(policy?.egress).not.toEqual(expect.arrayContaining([traefikEgressRule()])); + }); + it('selects all sandboxes of an agent by agent ID label', async () => { const app = { id: 'demo-app', diff --git a/src/server/services/svc.service.unit.spec.ts b/src/server/services/svc.service.unit.spec.ts index 6773dc02..5fa97379 100644 --- a/src/server/services/svc.service.unit.spec.ts +++ b/src/server/services/svc.service.unit.spec.ts @@ -71,7 +71,7 @@ describe('svc.service', () => { it('merges an App Node Port into an ingress rule for the same container port and protocol', async () => { const app = createApp({ appNetworkPolicy: { - id: 'policy-1', appId: 'demo-app', allowInternetAccess: true, + id: 'policy-1', appId: 'demo-app', allowInternetAccess: true, allowTraefikAccess: false, createdAt: new Date(), updatedAt: new Date(), rules: [{ id: 'rule-1', appNetworkPolicyId: 'policy-1', type: 'INGRESS', port: 300, protocol: 'UDP', @@ -112,7 +112,7 @@ describe('svc.service', () => { it('uses a Kubernetes-safe lowercase name for an ingress service port', async () => { const app = createApp({ appNetworkPolicy: { - id: 'policy-1', appId: 'demo-app', allowInternetAccess: true, + id: 'policy-1', appId: 'demo-app', allowInternetAccess: true, allowTraefikAccess: false, createdAt: new Date(), updatedAt: new Date(), rules: [{ id: 'rule-1', appNetworkPolicyId: 'policy-1', type: 'INGRESS', port: 3306, protocol: 'TCP', @@ -133,7 +133,7 @@ describe('svc.service', () => { const app = createApp({ appDomains: [{ id: 'domain-1', appId: 'demo-app', hostname: 'demo.example.com', port: 443, useSsl: true, redirectHttps: true, createdAt: new Date(), updatedAt: new Date() }], appNetworkPolicy: { - id: 'policy-1', appId: 'demo-app', allowInternetAccess: true, createdAt: new Date(), updatedAt: new Date(), + id: 'policy-1', appId: 'demo-app', allowInternetAccess: true, allowTraefikAccess: false, createdAt: new Date(), updatedAt: new Date(), rules: [ { id: 'rule-tcp', appNetworkPolicyId: 'policy-1', type: 'INGRESS', port: 443, protocol: 'TCP', targetAppId: null, targetAgentId: null, targetApp: null, targetAgent: null, createdAt: new Date(), updatedAt: new Date() }, { id: 'rule-udp', appNetworkPolicyId: 'policy-1', type: 'INGRESS', port: 443, protocol: 'UDP', targetAppId: null, targetAgentId: null, targetApp: null, targetAgent: null, createdAt: new Date(), updatedAt: new Date() }, diff --git a/src/shared/model/agent-extended.model.ts b/src/shared/model/agent-extended.model.ts index 2da2be06..9f2aca3f 100644 --- a/src/shared/model/agent-extended.model.ts +++ b/src/shared/model/agent-extended.model.ts @@ -83,6 +83,7 @@ const agentNetworkPolicyWriteMeta = z.object({ id: z.string().optional(), agentId: z.string().optional(), allowInternetAccess: z.boolean().optional().default(true), + allowTraefikAccess: z.boolean().optional().default(false), createdAt: z.date().optional(), updatedAt: z.date().optional(), }); diff --git a/src/shared/model/agent-network-policy-edit.model.ts b/src/shared/model/agent-network-policy-edit.model.ts index b04f1ed3..c21a7c1c 100644 --- a/src/shared/model/agent-network-policy-edit.model.ts +++ b/src/shared/model/agent-network-policy-edit.model.ts @@ -2,7 +2,8 @@ import { stringToBoolean } from "@/shared/utils/zod.utils"; import { z } from "zod"; export const agentNetworkPolicySettingsZodModel = z.object({ - allowInternetAccess: stringToBoolean, + allowInternetAccess: stringToBoolean.optional(), + allowTraefikAccess: stringToBoolean.optional(), }); export type AgentNetworkPolicySettingsModel = z.infer; diff --git a/src/shared/model/app-extended.model.ts b/src/shared/model/app-extended.model.ts index d7518279..37a00519 100644 --- a/src/shared/model/app-extended.model.ts +++ b/src/shared/model/app-extended.model.ts @@ -57,7 +57,11 @@ const appNetworkPolicyRuleWriteZodModel = AppNetworkPolicyRuleModel.omit({ const appNetworkPolicyWriteZodModel = AppNetworkPolicyModel.omit({ id: true, appId: true, createdAt: true, updatedAt: true, -}).extend({ id: z.string().optional(), rules: appNetworkPolicyRuleWriteZodModel.array() }); +}).extend({ + id: z.string().optional(), + allowTraefikAccess: z.boolean().optional().default(false), + rules: appNetworkPolicyRuleWriteZodModel.array(), +}); /** Write schema for POST upsert: id optional (absent = create), server meta fields stripped. */ export const AppExtendedWriteZodModel = AppModel diff --git a/src/shared/model/app-network-policy-edit.model.ts b/src/shared/model/app-network-policy-edit.model.ts index ec51d35f..c2bffb52 100644 --- a/src/shared/model/app-network-policy-edit.model.ts +++ b/src/shared/model/app-network-policy-edit.model.ts @@ -31,6 +31,7 @@ export const appNetworkPolicyConfigurationZodModel = z.object({ appId: z.string(), useNetworkPolicy: stringToBoolean, allowInternetAccess: stringToBoolean.optional().default(true), + allowTraefikAccess: stringToBoolean.optional().default(false), rules: z.array(appNetworkPolicyRuleEditZodModel).default([]), }); export type AppNetworkPolicyConfigurationModel = z.infer; diff --git a/src/shared/model/generated-zod/agentnetworkpolicy.ts b/src/shared/model/generated-zod/agentnetworkpolicy.ts index bb5e42b2..b07d86ed 100644 --- a/src/shared/model/generated-zod/agentnetworkpolicy.ts +++ b/src/shared/model/generated-zod/agentnetworkpolicy.ts @@ -6,6 +6,7 @@ export const AgentNetworkPolicyModel = z.object({ id: z.string(), agentId: z.string(), allowInternetAccess: z.boolean(), + allowTraefikAccess: z.boolean(), createdAt: z.date(), updatedAt: z.date(), }) diff --git a/src/shared/model/generated-zod/appnetworkpolicy.ts b/src/shared/model/generated-zod/appnetworkpolicy.ts index f244f397..43cf834e 100644 --- a/src/shared/model/generated-zod/appnetworkpolicy.ts +++ b/src/shared/model/generated-zod/appnetworkpolicy.ts @@ -6,6 +6,7 @@ export const AppNetworkPolicyModel = z.object({ id: z.string(), appId: z.string(), allowInternetAccess: z.boolean(), + allowTraefikAccess: z.boolean(), createdAt: z.date(), updatedAt: z.date(), }) diff --git a/src/shared/templates/network-policy-template.utils.ts b/src/shared/templates/network-policy-template.utils.ts index 97d7ce49..7465ccb0 100644 --- a/src/shared/templates/network-policy-template.utils.ts +++ b/src/shared/templates/network-policy-template.utils.ts @@ -10,6 +10,7 @@ export class NetworkPolicyTemplateUtils { const configuration: NonNullable = { allowInternetAccess: false, + allowTraefikAccess: app.appNetworkPolicy?.allowTraefikAccess ?? false, rules: existingRules, }; @@ -28,6 +29,7 @@ export class NetworkPolicyTemplateUtils { const configuration: NonNullable = { allowInternetAccess, + allowTraefikAccess: app.appNetworkPolicy?.allowTraefikAccess ?? false, rules: [ ...existingRules, { type, targetAppId: target.id, targetAgentId: null, port, protocol: 'TCP' }, diff --git a/src/shared/utils/app-network-policy-draft.utils.spec.ts b/src/shared/utils/app-network-policy-draft.utils.spec.ts index d0d5b63a..a14ab0d7 100644 --- a/src/shared/utils/app-network-policy-draft.utils.spec.ts +++ b/src/shared/utils/app-network-policy-draft.utils.spec.ts @@ -9,6 +9,7 @@ function app(): AppExtendedModel { appDomains: [], appNetworkPolicy: { allowInternetAccess: false, + allowTraefikAccess: false, rules: [{ id: 'rule-a', type: 'EGRESS', @@ -31,6 +32,7 @@ describe('AppNetworkPolicyDraftUtils', () => { appId: 'app-a', useNetworkPolicy: true, allowInternetAccess: false, + allowTraefikAccess: false, rules: [{ id: 'rule-a', type: 'EGRESS', diff --git a/src/shared/utils/app-network-policy-draft.utils.ts b/src/shared/utils/app-network-policy-draft.utils.ts index 76771224..a2275ec0 100644 --- a/src/shared/utils/app-network-policy-draft.utils.ts +++ b/src/shared/utils/app-network-policy-draft.utils.ts @@ -32,6 +32,7 @@ export class AppNetworkPolicyDraftUtils { appId: app.id, useNetworkPolicy: app.useNetworkPolicy, allowInternetAccess: app.appNetworkPolicy?.allowInternetAccess !== false, + allowTraefikAccess: app.appNetworkPolicy?.allowTraefikAccess === true, rules: (app.appNetworkPolicy?.rules ?? []).flatMap(rule => { const target = rule.targetApp ?? rule.targetAgent; const targetId = rule.targetAppId ?? rule.targetAgentId; @@ -68,7 +69,7 @@ export class AppNetworkPolicyDraftUtils { const rules = draft.rules .map(rule => NetworkPolicyRuleUtils.contentSignature(NetworkPolicyRuleUtils.fromEditRule(rule))) .sort(); - return [draft.appId, draft.useNetworkPolicy, draft.allowInternetAccess, ...rules].join(':'); + return [draft.appId, draft.useNetworkPolicy, draft.allowInternetAccess, draft.allowTraefikAccess, ...rules].join(':'); } static equals(left: AppNetworkPolicyDraft, right: AppNetworkPolicyDraft): boolean { @@ -119,6 +120,7 @@ export class AppNetworkPolicyDraftUtils { appId: draft.appId, useNetworkPolicy: draft.useNetworkPolicy, allowInternetAccess: draft.allowInternetAccess, + allowTraefikAccess: draft.allowTraefikAccess, rules: draft.rules.map(rule => ({ ...(rule.persistedId ? { id: rule.persistedId } : {}), type: rule.type, @@ -154,6 +156,7 @@ export class AppNetworkPolicyDraftUtils { appNetworkPolicy: { ...app.appNetworkPolicy, allowInternetAccess: draft.allowInternetAccess, + allowTraefikAccess: draft.allowTraefikAccess, rules: this.toGraphRules(draft), }, } as AppExtendedModel;