From ccaf1561270fa78246b97c999d13aa5b8f680db2 Mon Sep 17 00:00:00 2001 From: aperolschpritz Date: Tue, 10 Nov 2026 16:18:59 +0000 Subject: [PATCH] feat: add Traefik egress access to network policies Add an independent allowTraefikAccess flag to App and Agent network policies so workloads can reach other apps and agents through their exposed Traefik domains even when internet egress is disabled. The flag defaults to false and adds an egress rule for the Traefik pods without port restrictions. --- GLOSSARY.md | 9 ++- docs/adr/0016-traefik-access-egress.md | 5 ++ .../migration.sql | 2 + prisma/schema.prisma | 2 + .../v1/subitem-identity.integration.spec.ts | 1 + .../agent.service.integration.spec.ts | 2 + .../services/app.service.integration.spec.ts | 4 ++ .../network/agent-network-policy-card.tsx | 31 +++++++- .../app-network-policy-rule-section.tsx | 20 ++++++ .../details/network/policy/network-policy.tsx | 6 +- ...oject-network-graph-agent-context-menu.tsx | 31 +++++++- ...project-network-graph-app-context-menu.tsx | 10 ++- .../network-graph/project-network-graph.tsx | 10 ++- src/server/api/v1/agent/route.ts | 4 +- src/server/api/v1/app/route.ts | 2 +- .../services/agent-network-policy.service.ts | 16 ++++- .../agent-network-policy.service.unit.spec.ts | 38 ++++++++++ src/server/services/agent-template.service.ts | 1 + .../agent-template.service.unit.spec.ts | 2 +- .../services/app-network-policy.service.ts | 17 ++++- .../app-network-policy.service.unit.spec.ts | 21 +++++- src/server/services/network-policy.service.ts | 29 +++++++- .../network-policy.service.unit.spec.ts | 71 +++++++++++++++++++ src/server/services/svc.service.unit.spec.ts | 6 +- src/shared/model/agent-extended.model.ts | 1 + .../model/agent-network-policy-edit.model.ts | 3 +- src/shared/model/app-extended.model.ts | 6 +- .../model/app-network-policy-edit.model.ts | 1 + .../model/generated-zod/agentnetworkpolicy.ts | 1 + .../model/generated-zod/appnetworkpolicy.ts | 1 + .../network-policy-template.utils.ts | 2 + .../app-network-policy-draft.utils.spec.ts | 2 + .../utils/app-network-policy-draft.utils.ts | 5 +- 33 files changed, 337 insertions(+), 25 deletions(-) create mode 100644 docs/adr/0016-traefik-access-egress.md create mode 100644 prisma/migrations/20261110120000_add_allow_traefik_access/migration.sql 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;