diff --git a/.gitignore b/.gitignore index 03fae75bda..8a6fcc72a2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ uds-docs/** **.backup **/.playwright/** **/.playwright + +coverage/** diff --git a/docs/dev/authorization-policy-generation.md b/docs/dev/authorization-policy-generation.md new file mode 100644 index 0000000000..c918f33132 --- /dev/null +++ b/docs/dev/authorization-policy-generation.md @@ -0,0 +1,251 @@ +## Overview + +This guide describes how Istio AuthorizationPolicies are generated from the UDSPackage CR by the UDS Operator. These **ALLOW** policies are primarily used to enable ingress security within an Istio Ambient Mesh environment. + +The code responsible for generating these policies can be found [here](https://github.com/defenseunicorns/uds-core/blob/main/src/pepr/operator/controllers/network/authorizationPolicies.ts) and includes support for three rule types: +- `allow`: Direct ingress rules for services. +- `expose`: Gateway-based ingress exposure. +- `monitor`: Restricts access to metrics endpoints. + +Each rule is processed individually to generate a single Istio AuthorizationPolicy. + +--- + +## Policy Generation Flow + +1. **Input Collection** + - The operator reads the `spec.network.allow`, `spec.network.expose`, and `spec.monitor` fields from a UDSPackage. + +2. **Allow Rule Processing** + - Sources are computed based on `remoteGenerated`, `remoteNamespace`, and `remoteServiceAccount`. + - Port info is collected from `port` and `ports`. + - If `remoteServiceAccount` is present, a `principal` source is used, overriding namespace restrictions. + +3. **Expose Rule Processing** + - Uses `port` or `targetPort` for port resolution. + - Sources are determined by the selected gateway: + - Admin gateway → `cluster.local/ns/istio-admin-gateway/sa/admin-ingressgateway` + - Tenant gateway (default) → `cluster.local/ns/istio-tenant-gateway/sa/tenant-ingressgateway` + +4. **Monitor Rule Processing** + - Each monitor rule generates a policy allowing access from `monitoring` namespace to a specific port. + +5. **Policy Naming** + - All policies start with `protect--`. + - `allow` rules use either the `description` or a combination of selector and remote fields. + - `expose` rules follow `ingress---istio--gateway`. + +6. **Policy Application** + - Policies are applied via `K8s(AuthorizationPolicy).Apply()` with force enabled. + - `purgeOrphans` removes outdated or unused policies from previous generations. + +--- + +## Development Tips + +- **Rule Deduplication**: Currently, even identical selectors in different rules generate separate policies. +- **Troubleshooting**: Enable debug logging to inspect which policy is generated and applied. +- **Testing**: Use test UDSPackages with different `remoteGenerated` and gateway values to validate behavior. +- **Best Practices**: + - Avoid overly broad allow rules (e.g., `remoteGenerated: Anywhere`) unless absolutely necessary. + - Prefer using `remoteServiceAccount` for precise identity-based access. + +--- + +## Example Use Cases + +### Example 1: Allow Ingress from a Specific Namespace (No Selector) + +```yaml +spec: + network: + allow: + - direction: Ingress + remoteNamespace: "external-app" + port: 8080 +``` + +```yaml +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy +metadata: + name: protect-my-app-ingress-external-app + namespace: my-app-namespace + labels: + uds/package: my-app + uds/generation: "1" +spec: + action: ALLOW + rules: + - from: + - source: + namespaces: ["external-app"] + to: + - operation: + ports: ["8080"] +``` + +### Example 2: Allow Ingress Only to a Specific Pod Selector + +```yaml +spec: + network: + allow: + - direction: Ingress + remoteNamespace: "external-app" + selector: + app: "frontend" + port: 8080 +``` + +```yaml +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy +metadata: + name: protect-my-app-ingress-frontend + namespace: my-app-namespace + labels: + uds/package: my-app + uds/generation: "1" +spec: + action: ALLOW + selector: + matchLabels: + app: "frontend" + rules: + - from: + - source: + namespaces: ["external-app"] + to: + - operation: + ports: ["8080"] +``` + +### Example 3: Intra-Namespace Rule Without Port + +```yaml +spec: + network: + allow: + - direction: Ingress + remoteGenerated: IntraNamespace +``` + +```yaml +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy +metadata: + name: protect-loki-ingress-all + namespace: loki + labels: + uds/package: loki + uds/generation: "1" +spec: + action: ALLOW + rules: + - from: + - source: + namespaces: ["loki"] +``` + +### Example 4: Allow Anywhere Rule (No Namespace Restriction) + +```yaml +spec: + network: + allow: + - direction: Ingress + remoteGenerated: Anywhere + port: 80 +``` + +```yaml +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy +metadata: + name: protect-myapp-ingress-all + namespace: my-namespace + labels: + uds/package: myapp + uds/generation: "1" +spec: + action: ALLOW + rules: + - from: [] + to: + - operation: + ports: ["80"] +``` + +### Example 5: Expose Rule with Gateway Specification + +```yaml +spec: + network: + expose: + - port: 8080 + targetPort: 9090 + selector: + app: "backend" + gateway: Admin +``` + +```yaml +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy +metadata: + name: protect-my-app-ingress-9090-backend-istio-admin-gateway + namespace: my-app-namespace + labels: + uds/package: my-app + uds/generation: "1" +spec: + action: ALLOW + selector: + matchLabels: + app: "backend" + rules: + - from: + - source: + principals: ["cluster.local/ns/istio-admin-gateway/sa/admin-ingressgateway"] + to: + - operation: + ports: ["9090"] +``` + +### Example 6: Monitor Rule for Securing a Metrics Endpoint + +```yaml +spec: + monitor: + - description: Metrics + podSelector: + app.kubernetes.io/name: grafana + portName: service + selector: + app.kubernetes.io/name: grafana + targetPort: 3000 +``` + +```yaml +apiVersion: security.istio.io/v1beta1 +kind: AuthorizationPolicy +metadata: + name: protect-grafana-ingress-grafana-istio-tenant-gateway + namespace: grafana + labels: + uds/package: grafana + uds/generation: "1" +spec: + action: ALLOW + selector: + matchLabels: + app.kubernetes.io/name: grafana + rules: + - from: + - source: + namespaces: ["monitoring"] + to: + - operation: + ports: ["3000"] +``` diff --git a/docs/reference/configuration/Single Sign-On/auth-service.md b/docs/reference/configuration/Single Sign-On/auth-service.md index c1c29564d2..b0f2ebc688 100644 --- a/docs/reference/configuration/Single Sign-On/auth-service.md +++ b/docs/reference/configuration/Single Sign-On/auth-service.md @@ -30,3 +30,6 @@ The UDS Operator uses the first `redirectUris` to populate the `match.prefix` ho ::: For a complete example, see [app-authservice-tenant.yaml](https://github.com/defenseunicorns/uds-core/blob/main/src/test/app-authservice-tenant.yaml) + +## Limitations: +Authservice is intended for simple, basic protection scenarios where an absolute level of protection is acceptable. For more advanced authentication requirements, you should implement authentication directly in your application or via a more comprehensive solution. diff --git a/docs/reference/configuration/authorization-policies.md b/docs/reference/configuration/authorization-policies.md new file mode 100644 index 0000000000..07bbd0eb5a --- /dev/null +++ b/docs/reference/configuration/authorization-policies.md @@ -0,0 +1,99 @@ +--- +title: How Authorization Policies Protect Your Services +sidebar: + order: 3 +--- + +In clusters running Istio Ambient Mesh, UDS‑Core enforces **ingress network security** using Istio **ALLOW** AuthorizationPolicies. These policies are automatically generated for each application package you define with a [UDS Package](https://uds.defenseunicorns.com/reference/configuration/uds-operator/package/) resource. + +This document explains what this means for you as an application developer and how to take full advantage of the built-in security model. + +--- + +## Key Takeaways + +- **Ingress is denied by default.** UDS Core only allows what you explicitly configure using `allow` and `expose` rules. + +- **AuthorizationPolicies are ALLOW-based**, which means you must write **DENY** rules separately if you want to restrict internal traffic further. + +- **Use `remoteServiceAccount` wherever possible.** This provides the most secure and identity-based access control. + +- **Expose rules use gateways** to control what traffic enters your application. You can choose between: + - **Tenant Gateway** (default) + - **Admin Gateway** (used only when absolutely necessary) + +- **Monitoring ports are automatically secured** using rules that only allow the `monitoring` namespace to scrape metrics. + +--- + +## Best Practices for Secure Configuration + +### 1. Lock Down Ingress With `allow` + +```yaml +spec: + network: + allow: + - direction: Ingress + remoteNamespace: "external-app" + remoteServiceAccount: "my-client" + port: 8080 +``` + +> This ensures that only a workload running as this specific service account in another namespace can access your service. + +### 2. Expose Your Service Safely + +```yaml +spec: + network: + expose: + - port: 80 + targetPort: 8080 + gateway: Tenant +``` + +> This exposes your service at port 80 through the tenant gateway and maps it to your app’s port 8080. + +### 3. Enable Safe Monitoring + +```yaml +spec: + monitor: + - targetPort: 3000 + selector: + app.kubernetes.io/name: grafana +``` + +> This creates a rule that allows only Prometheus (from the `monitoring` namespace) to scrape your service. + +--- + +## Authservice Guidance + +If you're using Authservice, be aware that it is **only appropriate for simple access scenarios**, such as: + +- Protecting web UIs or dashboards +- Cases where access can be fully granted or denied with no granularity + +--- + +## How Istio Evaluates Policies + +Istio checks **DENY policies first**, then **ALLOW policies**. + +- The operator creates ALLOW policies to admit approved ingress traffic. +- You should create your own DENY policies for more fine-grained control. + +More info: [Istio Authorization Policy Evaluation](https://istio.io/latest/docs/concepts/security/#authorization-policy) + +--- + +## Summary + +- Ingress is denied by default. +- You allow ingress by defining `allow` or `expose` rules in your UDS Package resource definition. +- You can further tighten security using DENY policies. +- Use `remoteServiceAccount` for the strongest protection. +- Authservice is good for simple cases only—use stronger methods for complex needs. + diff --git a/docs/reference/configuration/custom resources/exemptions-v1alpha1-cr.md b/docs/reference/configuration/custom resources/exemptions-v1alpha1-cr.md index 1bd916f82d..2174bda692 100644 --- a/docs/reference/configuration/custom resources/exemptions-v1alpha1-cr.md +++ b/docs/reference/configuration/custom resources/exemptions-v1alpha1-cr.md @@ -2,8 +2,6 @@ title: Exemptions CR (v1alpha1) tableOfContents: maxHeadingLevel: 6 -sidebar: - order: 11 ---
diff --git a/docs/reference/configuration/custom resources/packages-v1alpha1-cr.md b/docs/reference/configuration/custom resources/packages-v1alpha1-cr.md index 06763bcafc..d358027b7f 100644 --- a/docs/reference/configuration/custom resources/packages-v1alpha1-cr.md +++ b/docs/reference/configuration/custom resources/packages-v1alpha1-cr.md @@ -2,8 +2,6 @@ title: Packages CR (v1alpha1) tableOfContents: maxHeadingLevel: 6 -sidebar: - order: 10 ---
@@ -126,7 +124,7 @@ sidebar: - descriptionstringA description of the policy, this will become part of the policy namedirectionstring (enum):
  • Ingress
  • Egress
The direction of the trafficlabelsThe labels to apply to the policypodLabelsDeprecated: use selectorportnumberThe port to allow (protocol is always TCP)portsnumber[]A list of ports to allow (protocol is always TCP)remoteCidrstringCustom generated policy CIDRremoteGeneratedstring (enum):
  • KubeAPI
  • KubeNodes
  • IntraNamespace
  • CloudMetadata
  • Anywhere
Custom generated remote selector for the policyremoteNamespacestringThe remote namespace to allow traffic to/from. Use * or empty string to allow all namespacesremotePodLabelsDeprecated: use remoteSelectorremoteSelectorThe remote pod selector labels to allow traffic to/fromselectorLabels to match pods in the namespace to apply the policy to. Leave empty to apply to all pods in the namespace + descriptionstringA description of the policy, this will become part of the policy namedirectionstring (enum):
  • Ingress
  • Egress
The direction of the trafficlabelsThe labels to apply to the policypodLabelsDeprecated: use selectorportnumberThe port to allow (protocol is always TCP)portsnumber[]A list of ports to allow (protocol is always TCP)remoteCidrstringCustom generated policy CIDRremoteGeneratedstring (enum):
  • KubeAPI
  • KubeNodes
  • IntraNamespace
  • CloudMetadata
  • Anywhere
Custom generated remote selector for the policyremoteNamespacestringThe remote namespace to allow traffic to/from. Use * or empty string to allow all namespacesremotePodLabelsDeprecated: use remoteSelectorremoteSelectorThe remote pod selector labels to allow traffic to/fromremoteServiceAccountstringThe remote service account to restrict incoming traffic from within the remote namespace. Only valid for Ingress rules.selectorLabels to match pods in the namespace to apply the policy to. Leave empty to apply to all pods in the namespace
diff --git a/schemas/package-v1alpha1.schema.json b/schemas/package-v1alpha1.schema.json index 53962944b7..bc79626ccc 100644 --- a/schemas/package-v1alpha1.schema.json +++ b/schemas/package-v1alpha1.schema.json @@ -224,6 +224,10 @@ }, "description": "Labels to match pods in the namespace to apply the policy to. Leave empty to apply to all pods in the namespace\nThe labels to apply to the policy\nDeprecated: use selector\nDeprecated: use remoteSelector\nThe remote pod selector labels to allow traffic to/from\nSpecifies attributes for the client.\nLabels to match pods to automatically protect with authservice. Leave empty to disable authservice protection\nConfiguration options for the mapper.\nA template for the generated secret" }, + "remoteServiceAccount": { + "type": "string", + "description": "The remote service account to restrict incoming traffic from within the remote\nnamespace. Only valid for Ingress rules." + }, "selector": { "type": "object", "additionalProperties": { @@ -958,6 +962,9 @@ "type": "object", "additionalProperties": false, "properties": { + "authorizationPolicyCount": { + "type": "integer" + }, "authserviceClients": { "type": "array", "items": { diff --git a/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts b/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts index 09db600007..7d6e88f789 100644 --- a/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts +++ b/src/pepr/operator/controllers/keycloak/authservice/authorization-policy.ts @@ -68,6 +68,7 @@ function jwtAuthZAuthorizationPolicy( namespace, }, spec: { + action: IstioAction.Deny, selector: { matchLabels: labelSelector, }, @@ -76,7 +77,7 @@ function jwtAuthZAuthorizationPolicy( from: [ { source: { - requestPrincipals: [`https://sso.${UDSConfig.domain}/realms/uds/*`], + notRequestPrincipals: [`https://sso.${UDSConfig.domain}/realms/uds/*`], }, }, ], diff --git a/src/pepr/operator/controllers/network/authorizationPolicies.spec.ts b/src/pepr/operator/controllers/network/authorizationPolicies.spec.ts new file mode 100644 index 0000000000..c22cc40fc6 --- /dev/null +++ b/src/pepr/operator/controllers/network/authorizationPolicies.spec.ts @@ -0,0 +1,893 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { Direction, Gateway, RemoteGenerated, UDSPackage } from "../../crd"; +import { Action, AuthorizationPolicy } from "../../crd/generated/istio/authorizationpolicy-v1beta1"; +import { generateAuthorizationPolicies } from "./authorizationPolicies"; + +jest.mock("../../../logger", () => ({ + setupLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + trace: jest.fn(), + }), + Component: { + OPERATOR_NETWORK: "OPERATOR_NETWORK", + }, +})); + +jest.mock("pepr", () => ({ + K8s: jest.fn(() => ({ + Apply: jest.fn().mockResolvedValue({}), + InNamespace: jest.fn().mockReturnThis(), + WithLabel: jest.fn().mockReturnThis(), + Get: jest.fn().mockResolvedValue({ items: [] }), + })), +})); + +jest.mock("./generators/cloudMetadata", () => ({ + META_IP: "169.254.169.254/32", +})); + +jest.mock("./generators/kubeAPI", () => ({ + kubeAPI: () => [{ ipBlock: { cidr: "10.0.0.1/32" } }], +})); + +jest.mock("./generators/kubeNodes", () => ({ + kubeNodes: () => [{ ipBlock: { cidr: "192.168.0.0/16" } }], +})); + +describe("authorization policy generation", () => { + test("should generate authpol with ipBlock for CloudMetadata", async () => { + const pkg: UDSPackage = { + metadata: { name: "cloud-metadata-test", namespace: "test-ns", generation: 1 }, + spec: { + network: { + allow: [ + { + direction: Direction.Ingress, + remoteGenerated: RemoteGenerated.CloudMetadata, + selector: { app: "cloud-metadata-test" }, + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "test-ns"); + expect(policies.length).toBe(1); + const policy = policies[0]; + expect(policy.metadata?.name).toBe( + "protect-cloud-metadata-test-ingress-cloud-metadata-test-cloudmetadata", + ); + expect(policy.spec?.rules?.[0].from?.[0].source).toEqual({ + ipBlocks: ["169.254.169.254/32"], + }); + }); + + test("should generate authpol with ipBlock from kubeAPI", async () => { + const pkg: UDSPackage = { + metadata: { name: "kubeapi-test", namespace: "test-ns", generation: 1 }, + spec: { + network: { + allow: [ + { + direction: Direction.Ingress, + remoteGenerated: RemoteGenerated.KubeAPI, + selector: { app: "kubeapi-test" }, + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "test-ns"); + expect(policies.length).toBe(1); + const policy = policies[0]; + expect(policy.metadata?.name).toBe("protect-kubeapi-test-ingress-kubeapi-test-kubeapi"); + expect(policy.spec?.rules?.[0].from?.[0].source).toEqual({ + ipBlocks: ["10.0.0.1/32"], + }); + }); + + test("should generate authpol with ipBlock from kubeNodes", async () => { + const pkg: UDSPackage = { + metadata: { name: "kubenodes-test", namespace: "test-ns", generation: 1 }, + spec: { + network: { + allow: [ + { + direction: Direction.Ingress, + remoteGenerated: RemoteGenerated.KubeNodes, + selector: { app: "kubenodes-test" }, + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "test-ns"); + expect(policies.length).toBe(1); + const policy = policies[0]; + expect(policy.metadata?.name).toBe("protect-kubenodes-test-ingress-kubenodes-test-kubenodes"); + expect(policy.spec?.rules?.[0].from?.[0].source).toEqual({ + ipBlocks: ["192.168.0.0/16"], + }); + }); + + test("should generate an authpol with ipBlocks from remoteCidr", async () => { + const pkg: UDSPackage = { + metadata: { name: "curl-pkg-remote-cidr", namespace: "curl-ns-remote-cidr", generation: 1 }, + spec: { + network: { + allow: [ + { + direction: Direction.Ingress, + remoteCidr: "0.0.0.0/0", + selector: { app: "curl-pkg-remote-cidr" }, + }, + ], + }, + }, + }; + + const policies: AuthorizationPolicy[] = await generateAuthorizationPolicies( + pkg, + "curl-ns-remote-cidr", + ); + expect(policies).toHaveLength(1); + const policy = policies[0]; + expect(policy.metadata?.namespace).toBe("curl-ns-remote-cidr"); + // The selector should match the rule's selector + expect(policy.spec?.selector?.matchLabels).toEqual({ app: "curl-pkg-remote-cidr" }); + + // The rule should have a "from" block with source containing ipBlocks + expect(policy.spec?.rules).toHaveLength(1); + const rule = policy.spec!.rules![0]; + + // Since remoteCidr was provided, the computed source should use ipBlocks + expect(rule.from).toBeDefined(); + expect(rule.from![0].source).toEqual({ ipBlocks: ["0.0.0.0/0"] }); + + // And no "to" clause should be present because no port was specified + expect(rule.to).toBeUndefined(); + + // Also verify that the action is Allow + expect(policy.spec?.action).toBe(Action.Allow); + }); + + test("should generate two distinct policies for expose and allow rules", async () => { + const pkg: UDSPackage = { + apiVersion: "uds.dev/v1alpha1", + kind: "Package", + metadata: { + name: "httpbin-other", + namespace: "authservice-test-app", + generation: 1, + }, + spec: { + sso: [ + { + name: "Demo SSO", + clientId: "uds-core-httpbin", + redirectUris: ["https://protected.uds.dev/login"], + enableAuthserviceSelector: { app: "httpbin" }, + groups: { anyOf: ["/UDS Core/Admin"] }, + }, + ], + network: { + expose: [ + { + service: "httpbin", + selector: { app: "httpbin" }, + gateway: Gateway.Tenant, + host: "protected", + port: 8000, + targetPort: 80, + }, + ], + allow: [ + { + direction: Direction.Ingress, + selector: { app: "httpbin" }, + ports: [80], + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "authservice-test-app"); + // We expect exactly two policies: one for the expose rule and one for the allow rule. + expect(policies.length).toBe(2); + + // Allow rule policy (generated via generateAllowName) + const allowPolicy = policies.find( + p => p.metadata?.name === "protect-httpbin-other-ingress-httpbin-default-all-pods", + ); + expect(allowPolicy).toBeDefined(); + expect(allowPolicy!.spec!.action).toBe(Action.Allow); + expect(allowPolicy!.spec!.selector?.matchLabels).toEqual({ app: "httpbin" }); + expect(allowPolicy!.spec!.rules![0].to).toEqual([{ operation: { ports: ["80"] } }]); + + // Expose rule policy (generated via generateExposeName) + const exposePolicy = policies.find( + p => p.metadata?.name === "protect-httpbin-other-ingress-80-httpbin-istio-tenant-gateway", + ); + expect(exposePolicy).toBeDefined(); + expect(exposePolicy!.spec!.action).toBe(Action.Allow); + // For expose, no selector is applied by default + expect(exposePolicy!.spec!.rules![0].from).toEqual([ + { + source: { + principals: ["cluster.local/ns/istio-tenant-gateway/sa/tenant-ingressgateway"], + }, + }, + ]); + expect(exposePolicy!.spec!.rules![0].to).toEqual([{ operation: { ports: ["80"] } }]); + }); + + test("should generate unique AuthorizationPolicies for expose rules with different ports", async () => { + const pkg: UDSPackage = { + metadata: { name: "test-tenant-app", namespace: "test-tenant-app", generation: 1 }, + spec: { + network: { + expose: [ + { + service: "test-tenant-app", + selector: { app: "test-tenant-app" }, + gateway: Gateway.Tenant, + host: "demo-8080", + port: 8080, + }, + { + service: "test-tenant-app", + selector: { app: "test-tenant-app" }, + gateway: Gateway.Tenant, + host: "demo-8081", + port: 8081, + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "test-tenant-app"); + expect(policies.length).toBe(2); + const names = policies.map(p => p.metadata?.name); + expect(new Set(names).size).toBe(2); + expect(names.some(name => name?.includes("8080"))).toBe(true); + expect(names.some(name => name?.includes("8081"))).toBe(true); + }); + + test("should generate correct AuthorizationPolicy for Loki", async () => { + const pkg: UDSPackage = { + metadata: { name: "loki", namespace: "loki", generation: 1 }, + spec: { + network: { + allow: [ + { + direction: Direction.Ingress, + remoteGenerated: RemoteGenerated.IntraNamespace, + // No port provided. + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "loki"); + // With one allow rule (Ingress/IntraNamespace), expect one policy + expect(policies.length).toBe(1); + const policy = policies[0]; + expect(policy.metadata?.name).toBe("protect-loki-ingress-all-pods-intranamespace"); + expect(policy.metadata?.namespace).toBe("loki"); + expect(policy.spec?.action).toBe(Action.Allow); + // The rule should only have a "from" clause with source { namespaces: ["loki"] } + expect(policy.spec?.rules).toEqual( + expect.arrayContaining([{ from: [{ source: { namespaces: ["loki"] } }] }]), + ); + }); + + test("should generate correct policies for Neuvector", async () => { + const pkg: UDSPackage = { + metadata: { name: "neuvector", namespace: "neuvector", generation: 1 }, + spec: { + network: { + expose: [ + { + service: "neuvector-service-webui", + selector: { app: "neuvector-manager-pod" }, + gateway: Gateway.Admin, + host: "neuvector", + port: 8443, + }, + ], + allow: [ + { direction: Direction.Ingress, remoteGenerated: RemoteGenerated.IntraNamespace }, + { direction: Direction.Egress, remoteGenerated: RemoteGenerated.IntraNamespace }, // Skipped. + { + direction: Direction.Ingress, + remoteGenerated: RemoteGenerated.Anywhere, + selector: { app: "neuvector-controller-pod" }, + port: 30443, + description: "Webhook", + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "neuvector"); + // With the current per-rule design we expect three policies + expect(policies.length).toBe(3); + + // Policy for the IntraNamespace allow rule (no selector) + const nsPolicy = policies.find( + p => p.metadata?.name === "protect-neuvector-ingress-all-pods-intranamespace", + ); + expect(nsPolicy).toBeDefined(); + expect(nsPolicy?.metadata?.namespace).toBe("neuvector"); + expect(nsPolicy?.spec?.action).toBe(Action.Allow); + expect(nsPolicy?.spec?.rules).toEqual( + expect.arrayContaining([{ from: [{ source: { namespaces: ["neuvector"] } }] }]), + ); + + // Policy for the controller allow rule ("Webhook") + const controllerPolicy = policies.find( + p => p.metadata?.name === "protect-neuvector-ingress-webhook", + ); + expect(controllerPolicy).toBeDefined(); + expect(controllerPolicy?.spec?.selector?.matchLabels).toEqual({ + app: "neuvector-controller-pod", + }); + expect(controllerPolicy?.spec?.action).toBe(Action.Allow); + expect(controllerPolicy?.spec?.rules).toEqual( + expect.arrayContaining([{ to: [{ operation: { ports: ["30443"] } }] }]), + ); + + // Policy for the expose rule (should use default base name) + const exposePolicy = policies.find( + p => + p.metadata?.name === + "protect-neuvector-ingress-8443-neuvector-manager-pod-istio-admin-gateway", + ); + expect(exposePolicy).toBeDefined(); + expect(exposePolicy?.spec?.selector?.matchLabels).toEqual({ app: "neuvector-manager-pod" }); + expect(exposePolicy?.spec?.action).toBe(Action.Allow); + expect(exposePolicy?.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [ + { + source: { + principals: ["cluster.local/ns/istio-admin-gateway/sa/admin-ingressgateway"], + }, + }, + ], + to: [{ operation: { ports: ["8443"] } }], + }, + ]), + ); + }); + + test("should generate correct AuthorizationPolicies for Vector", async () => { + const pkg: UDSPackage = { + metadata: { name: "vector", namespace: "vector", generation: 1 }, + spec: { + network: { + allow: [ + { + direction: Direction.Ingress, + selector: { "app.kubernetes.io/name": "vector" }, + remoteNamespace: "monitoring", + remoteSelector: { "app.kubernetes.io/name": "prometheus" }, + port: 9090, + description: "Prometheus Metrics", + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "vector"); + expect(policies.length).toBe(1); + const policy = policies[0]; + expect(policy.metadata?.name).toBe("protect-vector-ingress-prometheus-metrics"); + expect(policy.spec?.action).toBe(Action.Allow); + expect(policy.spec?.selector?.matchLabels).toEqual({ "app.kubernetes.io/name": "vector" }); + expect(policy.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [{ source: { namespaces: ["monitoring"] } }], + to: [{ operation: { ports: ["9090"] } }], + }, + ]), + ); + }); + + test("should generate correct AuthorizationPolicies for Velero", async () => { + const pkg: UDSPackage = { + metadata: { name: "velero", namespace: "velero", generation: 1 }, + spec: { + network: { + allow: [ + { + direction: Direction.Ingress, + selector: { "app.kubernetes.io/name": "velero" }, + remoteNamespace: "monitoring", + remoteSelector: { "app.kubernetes.io/name": "prometheus" }, + port: 8085, + description: "Protected Apps", + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "velero"); + // Expect one policy + expect(policies.length).toBe(1); + const policy = policies[0]; + expect(policy.metadata?.name).toBe("protect-velero-ingress-protected-apps"); + expect(policy.metadata?.namespace).toBe("velero"); + expect(policy.spec?.action).toBe(Action.Allow); + expect(policy.spec?.selector?.matchLabels).toEqual({ "app.kubernetes.io/name": "velero" }); + expect(policy.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [{ source: { namespaces: ["monitoring"] } }], + to: [{ operation: { ports: ["8085"] } }], + }, + ]), + ); + }); + + test("should generate correct AuthorizationPolicies for Authservice", async () => { + const pkg: UDSPackage = { + metadata: { name: "authservice", namespace: "authservice", generation: 1 }, + spec: { + network: { + allow: [ + { direction: Direction.Ingress, remoteGenerated: RemoteGenerated.IntraNamespace }, + { direction: Direction.Egress, remoteGenerated: RemoteGenerated.IntraNamespace }, + { + direction: Direction.Ingress, + selector: { "app.kubernetes.io/name": "authservice" }, + remoteNamespace: "", + port: 10003, + description: "Protected Apps", + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "authservice"); + // Expect two policies + expect(policies.length).toBe(2); + const nsPolicy = policies.find( + p => p.metadata?.name === "protect-authservice-ingress-all-pods-intranamespace", + ); + expect(nsPolicy).toBeDefined(); + expect(nsPolicy!.metadata?.namespace).toBe("authservice"); + expect(nsPolicy!.spec?.action).toBe(Action.Allow); + expect(nsPolicy!.spec?.rules).toEqual( + expect.arrayContaining([{ from: [{ source: { namespaces: ["authservice"] } }] }]), + ); + + const workloadPolicy = policies.find( + p => p.metadata?.name === "protect-authservice-ingress-protected-apps", + ); + expect(workloadPolicy).toBeDefined(); + expect(workloadPolicy!.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "authservice", + }); + expect(workloadPolicy!.spec?.action).toBe(Action.Allow); + expect(workloadPolicy!.spec?.rules).toEqual( + expect.arrayContaining([{ to: [{ operation: { ports: ["10003"] } }] }]), + ); + }); + + test("should generate correct AuthorizationPolicies for Prometheus-Stack", async () => { + const pkg: UDSPackage = { + metadata: { name: "prometheus-stack", namespace: "monitoring", generation: 1 }, + spec: { + network: { + allow: [ + { direction: Direction.Ingress, remoteGenerated: RemoteGenerated.IntraNamespace }, + { + direction: Direction.Ingress, + selector: { "app.kubernetes.io/name": "prometheus" }, + remoteNamespace: "grafana", + remoteSelector: { "app.kubernetes.io/name": "grafana" }, + port: 9090, + }, + { + direction: Direction.Ingress, + selector: { app: "kube-prometheus-stack-operator" }, + remoteGenerated: RemoteGenerated.Anywhere, + port: 10250, + description: "Webhook", + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "monitoring"); + // Expect three policies + expect(policies.length).toBe(3); + const nsPolicy = policies.find( + p => p.metadata?.name === "protect-prometheus-stack-ingress-all-pods-intranamespace", + ); + expect(nsPolicy).toBeDefined(); + expect(nsPolicy!.metadata?.namespace).toBe("monitoring"); + expect(nsPolicy!.spec?.action).toBe(Action.Allow); + expect(nsPolicy!.spec?.rules).toEqual( + expect.arrayContaining([{ from: [{ source: { namespaces: ["monitoring"] } }] }]), + ); + + const promPolicy = policies.find( + p => p.metadata?.name === "protect-prometheus-stack-ingress-prometheus-grafana-grafana", + ); + expect(promPolicy).toBeDefined(); + expect(promPolicy!.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "prometheus", + }); + expect(promPolicy!.spec?.action).toBe(Action.Allow); + expect(promPolicy!.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [{ source: { namespaces: ["grafana"] } }], + to: [{ operation: { ports: ["9090"] } }], + }, + ]), + ); + + const operatorPolicy = policies.find( + p => p.metadata?.name === "protect-prometheus-stack-ingress-webhook", + ); + expect(operatorPolicy).toBeDefined(); + expect(operatorPolicy!.spec?.selector?.matchLabels).toEqual({ + app: "kube-prometheus-stack-operator", + }); + expect(operatorPolicy!.spec?.action).toBe(Action.Allow); + expect(operatorPolicy!.spec?.rules).toEqual( + expect.arrayContaining([{ to: [{ operation: { ports: ["10250"] } }] }]), + ); + }); + + test("should generate correct AuthorizationPolicies for Grafana including monitor block", async () => { + const pkg: UDSPackage = { + metadata: { name: "grafana", namespace: "grafana", generation: 1 }, + spec: { + monitor: [ + { + description: "Metrics", + podSelector: { "app.kubernetes.io/name": "grafana" }, + selector: { "app.kubernetes.io/name": "grafana" }, + targetPort: 3000, + portName: "80", + }, + ], + network: { + expose: [ + { + service: "grafana", + selector: { "app.kubernetes.io/name": "grafana" }, + host: "grafana", + gateway: Gateway.Admin, + port: 80, + targetPort: 3000, + }, + ], + allow: [ + { + direction: Direction.Ingress, + remoteGenerated: RemoteGenerated.IntraNamespace, + ports: [3000], + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "grafana"); + // Expect three policies: one from expose, one from allow, and one monitor policy + expect(policies.length).toBe(3); + const exposePolicy = policies.find( + p => p.metadata?.name === "protect-grafana-ingress-3000-grafana-istio-admin-gateway", + ); + expect(exposePolicy).toBeDefined(); + expect(exposePolicy!.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "grafana", + }); + expect(exposePolicy!.spec?.action).toBe(Action.Allow); + expect(exposePolicy!.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [ + { + source: { + principals: ["cluster.local/ns/istio-admin-gateway/sa/admin-ingressgateway"], + }, + }, + ], + to: [{ operation: { ports: ["3000"] } }], + }, + ]), + ); + + const nsPolicy = policies.find( + p => p.metadata?.name === "protect-grafana-ingress-all-pods-intranamespace", + ); + expect(nsPolicy).toBeDefined(); + expect(nsPolicy!.metadata?.namespace).toBe("grafana"); + expect(nsPolicy!.spec?.action).toBe(Action.Allow); + expect(nsPolicy!.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [{ source: { namespaces: ["grafana"] } }], + to: [{ operation: { ports: ["3000"] } }], + }, + ]), + ); + + const monitorPolicy = policies.find( + p => p.metadata?.name === "protect-grafana-monitor-3000-grafana-workload", + ); + expect(monitorPolicy).toBeDefined(); + expect(monitorPolicy!.metadata?.namespace).toBe("grafana"); + expect(monitorPolicy!.spec?.action).toBe(Action.Allow); + expect(monitorPolicy!.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "grafana", + }); + expect(monitorPolicy!.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [ + { + source: { + principals: ["cluster.local/ns/monitoring/sa/kube-prometheus-stack-prometheus"], + }, + }, + ], + to: [{ operation: { ports: ["3000"] } }], + }, + ]), + ); + }); + + test("should generate correct AuthorizationPolicies for Keycloak", async () => { + const pkg: UDSPackage = { + metadata: { name: "keycloak", namespace: "keycloak", generation: 1 }, + spec: { + monitor: [ + { + description: "Metrics", + podSelector: { "app.kubernetes.io/name": "keycloak" }, + selector: { + "app.kubernetes.io/name": "keycloak", + "app.kubernetes.io/component": "http", + }, + targetPort: 9000, + portName: "http-metrics", + }, + ], + network: { + allow: [ + { + description: "UDS Operator", + direction: Direction.Ingress, + selector: { "app.kubernetes.io/name": "keycloak" }, + remoteNamespace: "pepr-system", + remoteSelector: { app: "pepr-uds-core-watcher" }, + port: 8080, + }, + { + description: "Keycloak backchannel access", + direction: Direction.Ingress, + selector: { "app.kubernetes.io/name": "keycloak" }, + remoteNamespace: "*", + port: 8080, + }, + { + description: "OCSP Lookup", + direction: Direction.Egress, + selector: { "app.kubernetes.io/name": "keycloak" }, + ports: [443, 80], + remoteGenerated: RemoteGenerated.Anywhere, + }, + ], + expose: [ + { + description: "remove private paths from public gateway", + host: "sso", + service: "keycloak-http", + selector: { "app.kubernetes.io/name": "keycloak" }, + port: 8080, + advancedHTTP: { + match: [ + { name: "redirect-welcome", uri: { exact: "/" } }, + { name: "redirect-admin", uri: { prefix: "/admin" } }, + { name: "redirect-master-realm", uri: { prefix: "/realms/master" } }, + { name: "redirect-metrics", uri: { prefix: "/metrics" } }, + ], + redirect: { uri: "/realms/uds/account" }, + headers: { + request: { + remove: ["istio-mtls-client-certificate"], + add: { "istio-mtls-client-certificate": "%DOWNSTREAM_PEER_CERT%" }, + }, + }, + }, + }, + { + description: "public auth access with optional client certificate", + service: "keycloak-http", + selector: { "app.kubernetes.io/name": "keycloak" }, + host: "sso", + port: 8080, + advancedHTTP: { + headers: { + request: { + remove: ["istio-mtls-client-certificate"], + add: { "istio-mtls-client-certificate": "%DOWNSTREAM_PEER_CERT%" }, + }, + }, + }, + }, + { + description: "admin access with optional client certificate", + service: "keycloak-http", + selector: { "app.kubernetes.io/name": "keycloak" }, + gateway: Gateway.Admin, + host: "keycloak", + port: 8080, + advancedHTTP: { + headers: { + request: { + remove: ["istio-mtls-client-certificate"], + add: { "istio-mtls-client-certificate": "%DOWNSTREAM_PEER_CERT%" }, + }, + }, + }, + }, + ], + }, + }, + }; + + const policies = await generateAuthorizationPolicies(pkg, "keycloak"); + // We expect 6 policies + expect(policies.length).toBe(6); + + // 1. UDS Operator allow rule + const udsOperatorPol = policies.find( + p => p.metadata?.name === "protect-keycloak-ingress-uds-operator", + ); + expect(udsOperatorPol).toBeDefined(); + expect(udsOperatorPol?.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "keycloak", + }); + expect(udsOperatorPol?.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [{ source: { namespaces: ["pepr-system"] } }], + to: [{ operation: { ports: ["8080"] } }], + }, + ]), + ); + + // 2. Keycloak backchannel access allow rule + const backchannelPol = policies.find( + p => p.metadata?.name === "protect-keycloak-ingress-keycloak-backchannel-access", + ); + expect(backchannelPol).toBeDefined(); + expect(backchannelPol?.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "keycloak", + }); + expect(backchannelPol?.spec?.rules).toEqual( + expect.arrayContaining([{ to: [{ operation: { ports: ["8080"] } }] }]), + ); + + // 3. Expose rule: remove private paths from public gateway + const removePathsPol = policies.find( + p => p.metadata?.name === "protect-keycloak-ingress-8080-keycloak-istio-tenant-gateway", + ); + expect(removePathsPol).toBeDefined(); + expect(removePathsPol?.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "keycloak", + }); + expect(removePathsPol?.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [ + { + source: { + principals: ["cluster.local/ns/istio-tenant-gateway/sa/tenant-ingressgateway"], + }, + }, + ], + to: [{ operation: { ports: ["8080"] } }], + }, + ]), + ); + + // 4. Expose rule: public auth access with optional client certificate + const publicAuthPol = policies.find( + p => p.metadata?.name === "protect-keycloak-ingress-8080-keycloak-istio-tenant-gateway", + ); + expect(publicAuthPol).toBeDefined(); + expect(publicAuthPol?.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "keycloak", + }); + expect(publicAuthPol?.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [ + { + source: { + principals: ["cluster.local/ns/istio-tenant-gateway/sa/tenant-ingressgateway"], + }, + }, + ], + to: [{ operation: { ports: ["8080"] } }], + }, + ]), + ); + + // 5. Expose rule: admin access with optional client certificate + const adminAuthPol = policies.find( + p => p.metadata?.name === "protect-keycloak-ingress-8080-keycloak-istio-admin-gateway", + ); + expect(adminAuthPol).toBeDefined(); + expect(adminAuthPol?.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "keycloak", + }); + expect(adminAuthPol?.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [ + { + source: { + principals: ["cluster.local/ns/istio-admin-gateway/sa/admin-ingressgateway"], + }, + }, + ], + to: [{ operation: { ports: ["8080"] } }], + }, + ]), + ); + + // 6. Monitor rule: Metrics + const monitorPol = policies.find( + p => p.metadata?.name === "protect-keycloak-monitor-9000-keycloak-workload", + ); + expect(monitorPol).toBeDefined(); + expect(monitorPol?.metadata?.namespace).toBe("keycloak"); + expect(monitorPol?.spec?.action).toBe(Action.Allow); + expect(monitorPol?.spec?.selector?.matchLabels).toEqual({ + "app.kubernetes.io/name": "keycloak", + }); + expect(monitorPol?.spec?.rules).toEqual( + expect.arrayContaining([ + { + from: [ + { + source: { + principals: ["cluster.local/ns/monitoring/sa/kube-prometheus-stack-prometheus"], + }, + }, + ], + to: [{ operation: { ports: ["9000"] } }], + }, + ]), + ); + }); +}); diff --git a/src/pepr/operator/controllers/network/authorizationPolicies.ts b/src/pepr/operator/controllers/network/authorizationPolicies.ts new file mode 100644 index 0000000000..3b9e3f5bed --- /dev/null +++ b/src/pepr/operator/controllers/network/authorizationPolicies.ts @@ -0,0 +1,285 @@ +/** + * Copyright 2025 Defense Unicorns + * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial + */ + +import { K8s } from "pepr"; +import { Component, setupLogger } from "../../../logger"; +import { Allow, Expose, Gateway, Monitor, RemoteGenerated, UDSPackage } from "../../crd"; +import { + Action, + AuthorizationPolicy, + Rule, + Source, +} from "../../crd/generated/istio/authorizationpolicy-v1beta1"; +import { getOwnerRef, purgeOrphans, sanitizeResourceName } from "../utils"; +import { META_IP } from "./generators/cloudMetadata"; +import { kubeAPI } from "./generators/kubeAPI"; +import { kubeNodes } from "./generators/kubeNodes"; + +const log = setupLogger(Component.OPERATOR_NETWORK); + +// Constants for gateway principals. +const ADMIN_INGRESS = "cluster.local/ns/istio-admin-gateway/sa/admin-ingressgateway"; +const TENANT_INGRESS = "cluster.local/ns/istio-tenant-gateway/sa/tenant-ingressgateway"; +const PASSTHROUGH_INGRESS = + "cluster.local/ns/istio-passthrough-gateway/sa/passthrough-ingressgateway"; +const PROMETHEUS_PRINCIPAL = "cluster.local/ns/monitoring/sa/kube-prometheus-stack-prometheus"; + +/** + * Generates a unique name for a Monitor rule. + * Combines the target port and a derived name from the pod selector or fallback selector. + * Prioritizes "app" or "app.kubernetes.io/name" label values to form a stable, readable base. + * Falls back to joining all selector values, or "workload" if none exist. + */ +function generateMonitorName(monitor: Monitor): string { + const selector = monitor.podSelector ?? monitor.selector ?? {}; + const portPart = monitor.targetPort?.toString() ?? "unknown-port"; + const baseName = + selector["app"]?.replace(/-pod$/, "") ?? + (selector["app.kubernetes.io/name"] + ? selector["app.kubernetes.io/name"].replace(/-pod$/, "") + "-workload" + : undefined) ?? + (Object.values(selector).join("-") || "workload"); + return `monitor-${portPart}-${baseName}`; +} + +/** + * Generates a unique name for an Allow rule. + * Uses the description if provided; otherwise a combination of the selector values + * and remote properties is used. + */ +function generateAllowName(rule: Allow): string { + const { description, selector, remoteGenerated, remoteNamespace, remoteSelector } = rule; + const baseName = + description || + [ + Object.values(selector || { default: "all pods" }).join("-"), + remoteGenerated || [ + remoteNamespace || "default", + Object.values(remoteSelector || { default: "all pods" }).join("-"), + ], + ] + .flat() + .join("-"); + return `ingress-${baseName}`; +} + +/** + * Generates a unique name for an Expose rule using effective port, selector, and gateway. + */ +function generateExposeName(rule: Expose): string { + const effectivePort = rule.targetPort ?? rule.port; + const selPart = rule.selector ? Object.values(rule.selector).join("-") : "all"; + const gateway = rule.gateway || "tenant"; + return `ingress-${effectivePort}-${selPart}-istio-${gateway}-gateway`; +} + +/** + * Processes an Allow rule to extract its effective source and ports. + */ +function processAllowRule(rule: Allow, pkgNamespace: string): { source: Source; ports: string[] } { + const ports: string[] = []; + if (rule.port !== undefined) ports.push(rule.port.toString()); + if (rule.ports) ports.push(...rule.ports.map(p => p.toString())); + + let source: Source = {}; + + const hasRemoteSA = rule.remoteServiceAccount?.trim(); + const hasRemoteNS = rule.remoteNamespace?.trim(); + + if (hasRemoteSA) { + const ns = hasRemoteNS || pkgNamespace; + source = { + principals: [`cluster.local/ns/${ns}/sa/${rule.remoteServiceAccount}`], + }; + } else if (rule.remoteCidr) { + source = { ipBlocks: [rule.remoteCidr] }; + } else if (rule.remoteGenerated) { + switch (rule.remoteGenerated) { + case RemoteGenerated.CloudMetadata: + source = { ipBlocks: [META_IP] }; + break; + case RemoteGenerated.KubeAPI: + source = { + ipBlocks: kubeAPI() + .map((peer: { ipBlock?: { cidr: string } }) => peer.ipBlock?.cidr) + .filter((cidr): cidr is string => typeof cidr === "string"), + }; + break; + case RemoteGenerated.KubeNodes: + source = { + ipBlocks: kubeNodes() + .map((peer: { ipBlock?: { cidr: string } }) => peer.ipBlock?.cidr) + .filter((cidr): cidr is string => typeof cidr === "string"), + }; + break; + case RemoteGenerated.IntraNamespace: + source = { namespaces: [pkgNamespace] }; + break; + case RemoteGenerated.Anywhere: + source = {}; + break; + } + } else if (rule.remoteNamespace === "" || rule.remoteNamespace === "*") { + source = {}; + } else if (rule.remoteNamespace) { + source = { namespaces: [rule.remoteNamespace] }; + } + return { source, ports }; +} + +/** + * Processes an Expose rule to extract its effective source and ports. + */ +function processExposeRule(rule: Expose): { source: Source; ports: string[] } { + const ports: string[] = []; + const effectivePort = rule.targetPort ?? rule.port; + if (effectivePort !== undefined) { + ports.push(effectivePort.toString()); + } + const source = + rule.gateway === Gateway.Admin + ? { principals: [ADMIN_INGRESS] } + : rule.gateway === Gateway.Passthrough + ? { principals: [PASSTHROUGH_INGRESS] } + : { principals: [TENANT_INGRESS] }; + return { source, ports }; +} + +/** + * Helper to determine if an object is empty. + */ +function isEmpty(obj: object): boolean { + return Object.keys(obj).length === 0; +} + +/** + * Helper to build an AuthorizationPolicy from rule details. + * If the computed source is empty, the "from" field is omitted. + */ +function buildAuthPolicy( + policyName: string, + pkg: UDSPackage, + selector: Record | undefined, + source: Source, + ports: string[], + additionalLabels?: Record, +): AuthorizationPolicy { + const ruleEntry: Rule = {}; + if (!isEmpty(source)) { + ruleEntry.from = [{ source }]; + } + if (ports.length > 0) { + ruleEntry.to = [{ operation: { ports } }]; + } + + const pkgName = pkg.metadata?.name ?? "unknown"; + const pkgNamespace = pkg.metadata?.namespace ?? "default"; + const generation = pkg.metadata?.generation?.toString() ?? "0"; + + return { + apiVersion: "security.istio.io/v1beta1", + kind: "AuthorizationPolicy", + metadata: { + name: policyName, + namespace: pkgNamespace, + labels: { + "uds/package": pkgName, + "uds/generation": generation, + "uds/for": "network", + ...additionalLabels, + }, + ownerReferences: getOwnerRef(pkg), + }, + spec: { + action: Action.Allow, + ...(selector ? { selector: { matchLabels: selector } } : {}), + rules: [ruleEntry], + }, + }; +} + +/** + * Generate and apply Istio Authorization Policies for a given UDSPackage. + */ +export async function generateAuthorizationPolicies( + pkg: UDSPackage, + pkgNamespace: string, +): Promise { + const pkgName = pkg.metadata?.name ?? "unknown"; + const generation = pkg.metadata?.generation?.toString() ?? "0"; + log.info( + `Starting authorization policy generation for package "${pkgName}" in namespace "${pkgNamespace}" (generation ${generation}).`, + ); + + const policies: AuthorizationPolicy[] = []; + + // Process allow rules. + if (pkg.spec?.network?.allow) { + for (const rule of pkg.spec.network.allow) { + if (rule.direction === "Egress") continue; + const { source, ports } = processAllowRule(rule, pkgNamespace); + const policyName = sanitizeResourceName(`protect-${pkgName}-${generateAllowName(rule)}`); + const additionalLabels: Record | undefined = rule.remoteGenerated + ? { "uds/generated": rule.remoteGenerated } + : undefined; + const authPolicy = buildAuthPolicy( + policyName, + pkg, + rule.selector, + source, + ports, + additionalLabels, + ); + policies.push(authPolicy); + log.trace(`Generated authpol: ${authPolicy.metadata?.name}`); + } + } + + // Process expose rules. + if (pkg.spec?.network?.expose) { + for (const rule of pkg.spec.network.expose) { + const { source, ports } = processExposeRule(rule); + const policyName = sanitizeResourceName(`protect-${pkgName}-${generateExposeName(rule)}`); + const authPolicy = buildAuthPolicy(policyName, pkg, rule.selector, source, ports); + policies.push(authPolicy); + log.trace(`Generated authpol: ${authPolicy.metadata?.name}`); + } + } + + // Process monitor rules. + if (pkg.spec?.monitor) { + for (const monitor of pkg.spec.monitor) { + const selector = monitor.podSelector ?? monitor.selector; + const source: Source = { principals: [PROMETHEUS_PRINCIPAL] }; + const ports: string[] = [monitor.targetPort.toString()]; + const policyName = sanitizeResourceName(`protect-${pkgName}-${generateMonitorName(monitor)}`); + const authPolicy = buildAuthPolicy(policyName, pkg, selector, source, ports); + policies.push(authPolicy); + log.trace(`Generated monitor authpol: ${authPolicy.metadata?.name}`); + } + } + + // Apply policies concurrently. + for (const policy of policies) { + try { + await K8s(AuthorizationPolicy).Apply(policy, { force: true }); + log.trace( + `Applied AuthorizationPolicy ${policy.metadata?.name} in namespace ${policy.metadata?.namespace}`, + ); + } catch (err) { + log.error( + err, + `Error applying AuthorizationPolicy ${policy.metadata?.name} in namespace ${policy.metadata?.namespace}`, + ); + throw err; // Rethrow to fail the reconciliation process. + } + } + + await purgeOrphans(generation, pkgNamespace, pkgName, AuthorizationPolicy, log, { + "uds/for": "network", + }); + + return policies; +} diff --git a/src/pepr/operator/controllers/network/generators/kubeAPI.spec.ts b/src/pepr/operator/controllers/network/generators/kubeAPI.spec.ts index 0de63061d3..c2adb43e7b 100644 --- a/src/pepr/operator/controllers/network/generators/kubeAPI.spec.ts +++ b/src/pepr/operator/controllers/network/generators/kubeAPI.spec.ts @@ -5,7 +5,12 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { K8s, kind } from "pepr"; -import { updateAPIServerCIDR, updateKubeAPINetworkPolicies } from "./kubeAPI"; +import { AuthorizationPolicy } from "../../../crd/generated/istio/authorizationpolicy-v1beta1"; +import { + updateAPIServerCIDR, + updateKubeAPIAuthorizationPolicies, + updateKubeAPINetworkPolicies, +} from "./kubeAPI"; type KubernetesList = { items: T[]; @@ -575,3 +580,96 @@ describe("updateKubeAPINetworkPolicies", () => { expect(mockApply).not.toHaveBeenCalled(); // No policies to update }); }); + +describe("updateKubeAPIAuthorizationPolicies", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should not update a policy if ipBlocks are already correct", async () => { + const newPeers = [{ ipBlock: { cidr: "10.0.0.1/32" } }, { ipBlock: { cidr: "10.0.0.2/32" } }]; + + // Simulate a policy that already has the correct ipBlocks. + mockGet.mockResolvedValue({ + items: [ + { + metadata: { name: "authpol-1", namespace: "default" }, + spec: { + rules: [{ from: [{ source: { ipBlocks: ["10.0.0.1/32", "10.0.0.2/32"] } }] }], + }, + }, + ], + } as unknown as KubernetesList); + + await updateKubeAPIAuthorizationPolicies(newPeers); + + expect(mockApply).not.toHaveBeenCalled(); + }); + + it("should update a policy if ipBlocks are outdated", async () => { + const newPeers = [{ ipBlock: { cidr: "10.0.0.1/32" } }, { ipBlock: { cidr: "10.0.0.2/32" } }]; + + // Simulate a policy that currently has outdated ipBlocks. + mockGet.mockResolvedValue({ + items: [ + { + metadata: { name: "authpol-1", namespace: "default", managedFields: {} }, + spec: { + rules: [{ from: [{ source: { ipBlocks: ["192.168.1.0/32"] } }] }], + }, + }, + ], + } as unknown as KubernetesList); + + await updateKubeAPIAuthorizationPolicies(newPeers); + + expect(mockApply).toHaveBeenCalled(); + const updatedPolicy = mockApply.mock.calls[0][0] as AuthorizationPolicy; + expect(updatedPolicy.spec!.rules![0].from![0].source!.ipBlocks).toEqual([ + "10.0.0.1/32", + "10.0.0.2/32", + ]); + }); + + it("should create a 'from' entry if missing", async () => { + const newPeers = [{ ipBlock: { cidr: "10.0.0.1/32" } }]; + + // Simulate a policy with no 'from' field in its rule. + mockGet.mockResolvedValue({ + items: [ + { + metadata: { name: "authpol-2", namespace: "default", managedFields: {} }, + spec: { + rules: [{}], + }, + }, + ], + } as unknown as KubernetesList); + + await updateKubeAPIAuthorizationPolicies(newPeers); + + expect(mockApply).toHaveBeenCalled(); + const updatedPolicy = mockApply.mock.calls[0][0] as AuthorizationPolicy; + expect(updatedPolicy.spec!.rules![0].from![0].source!.ipBlocks).toEqual(["10.0.0.1/32"]); + }); + + it("should log a warning for policies with missing rules and not update", async () => { + const newPeers = [{ ipBlock: { cidr: "10.0.0.1/32" } }]; + + // Simulate a policy that has an empty rules array. + mockGet.mockResolvedValue({ + items: [ + { + metadata: { name: "authpol-3", namespace: "default" }, + spec: { + rules: [], + }, + }, + ], + } as unknown as KubernetesList); + + await updateKubeAPIAuthorizationPolicies(newPeers); + + expect(mockApply).not.toHaveBeenCalled(); + }); +}); diff --git a/src/pepr/operator/controllers/network/generators/kubeAPI.ts b/src/pepr/operator/controllers/network/generators/kubeAPI.ts index 6e90e0d922..180cc2497a 100644 --- a/src/pepr/operator/controllers/network/generators/kubeAPI.ts +++ b/src/pepr/operator/controllers/network/generators/kubeAPI.ts @@ -9,6 +9,7 @@ import { K8s, kind, R } from "pepr"; import { UDSConfig } from "../../../../config"; import { Component, setupLogger } from "../../../../logger"; import { RemoteGenerated } from "../../../crd"; +import { AuthorizationPolicy } from "../../../crd/generated/istio/authorizationpolicy-v1beta1"; import { retryWithDelay } from "../../utils"; import { anywhere } from "./anywhere"; @@ -146,6 +147,9 @@ export async function updateAPIServerCIDR(svc: kind.Service, slice: kind.Endpoin // Update NetworkPolicies await updateKubeAPINetworkPolicies(apiServerPeers); + + // Update AuthorizationPolicies + await updateKubeAPIAuthorizationPolicies(apiServerPeers); } else { log.warn("No peers found for the API server CIDR update."); } @@ -217,6 +221,83 @@ export async function updateKubeAPINetworkPolicies(newPeers: V1NetworkPolicyPeer } } +/** + * Updates the AuthorizationPolicies for KubeAPI. + * + * This function takes an array of V1NetworkPolicyPeer objects (newPeers) representing + * the latest API server CIDRs, extracts the CIDR strings, and then queries for all + * AuthorizationPolicies labeled with "uds/generated" equal to RemoteGenerated.KubeAPI. + * For each policy, it compares the existing IP blocks in the "from" field with the new IP blocks. + * If they differ, the policy is updated (after clearing managedFields to prevent server-side apply issues) + * and re-applied. + * + * @param {V1NetworkPolicyPeer[]} newPeers - An array of peer objects containing the updated API server CIDRs. + * @returns {Promise} A promise that resolves once the update process is complete. + */ +export async function updateKubeAPIAuthorizationPolicies( + newPeers: V1NetworkPolicyPeer[], +): Promise { + // Convert the cached peers to an array of CIDR strings. + const newIpBlocks = newPeers + .map(peer => peer.ipBlock?.cidr) + .filter((cidr): cidr is string => typeof cidr === "string"); + + // Query for AuthorizationPolicies with the generated label for KubeAPI. + const authPols = await K8s(AuthorizationPolicy) + .WithLabel("uds/generated", RemoteGenerated.KubeAPI) + .Get(); + + if (authPols.items.length > 0) { + const summary = authPols.items + .map(pol => `name: ${pol.metadata?.name}, namespace: ${pol.metadata?.namespace}`) + .join(" | "); + log.trace(`Fetched ${authPols.items.length} AuthorizationPolicies: ${summary}`); + } + + for (const pol of authPols.items) { + // Safety check: ensure the policy has rules. + if (!pol.spec || !pol.spec.rules || pol.spec.rules.length === 0) { + log.warn( + `AuthorizationPolicy ${pol.metadata?.namespace}/${pol.metadata?.name} is missing rules.`, + ); + continue; + } + + let updateRequired = false; + const rule = pol.spec.rules[0]; + // Check if a "from" entry exists and contains ipBlocks. + if (rule.from && rule.from.length > 0 && rule.from[0].source?.ipBlocks) { + const oldIpBlocks = rule.from[0].source.ipBlocks; + if (!R.equals(oldIpBlocks, newIpBlocks)) { + rule.from[0].source.ipBlocks = newIpBlocks; + updateRequired = true; + } + } else { + // If not present, create it. + rule.from = [{ source: { ipBlocks: newIpBlocks } }]; + updateRequired = true; + } + + if (updateRequired) { + // Clean managedFields to avoid server-side apply issues. + if (pol.metadata) { + pol.metadata.managedFields = undefined; + } + try { + await K8s(AuthorizationPolicy).Apply(pol, { force: true }); + log.debug( + `Updated KubeAPI AuthorizationPolicy ${pol.metadata?.namespace}/${pol.metadata?.name}`, + ); + } catch (err) { + log.error( + err, + `Failed to update AuthorizationPolicy ${pol.metadata?.namespace}/${pol.metadata?.name}`, + ); + } + } + } +} + /** * Fetches the Kubernetes Service object for the API server. * diff --git a/src/pepr/operator/controllers/network/generators/kubeNodes.spec.ts b/src/pepr/operator/controllers/network/generators/kubeNodes.spec.ts index 9ddd137df4..600e69ff1d 100644 --- a/src/pepr/operator/controllers/network/generators/kubeNodes.spec.ts +++ b/src/pepr/operator/controllers/network/generators/kubeNodes.spec.ts @@ -3,22 +3,29 @@ * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial */ -import { beforeEach, beforeAll, describe, expect, it, jest } from "@jest/globals"; +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { V1NetworkPolicyList } from "@kubernetes/client-node"; +import { K8s, kind } from "pepr"; +import { AuthorizationPolicy } from "../../../crd/generated/istio/authorizationpolicy-v1beta1"; +import { anywhere } from "./anywhere"; import { initAllNodesTarget, kubeNodes, + updateKubeNodesAuthorizationPolicies, updateKubeNodesFromCreateUpdate, updateKubeNodesFromDelete, } from "./kubeNodes"; -import { K8s, kind } from "pepr"; -import { V1NetworkPolicyList } from "@kubernetes/client-node"; -import { anywhere } from "./anywhere"; type KubernetesList = { items: T[]; }; +type MockNode = { + metadata: { name: string }; + status: { addresses: { type: string; address: string }[] }; +}; + jest.mock("pepr", () => { const originalModule = jest.requireActual("pepr") as object; return { @@ -31,6 +38,131 @@ jest.mock("pepr", () => { }; }); +describe("updateKubeNodesAuthorizationPolicies", () => { + const mockApply = jest.fn(); + const mockK8sGetNodes = jest.fn<() => Promise>>(); + const mockGetNetworkPolicies = jest.fn<() => Promise>>(); + const mockGetAuthPolicies = jest.fn<() => Promise>>(); + + (K8s as jest.Mock).mockImplementation(() => ({ + Get: mockK8sGetNodes, + WithLabel: jest.fn(() => ({ + Get: mockGetAuthPolicies, + })), + Apply: mockApply, + })); + + beforeEach(async () => { + jest.clearAllMocks(); + mockGetAuthPolicies.mockReset(); + mockGetNetworkPolicies.mockResolvedValue({ items: [] }); + mockK8sGetNodes.mockResolvedValue({ items: [] }); // ensures nodeSet starts empty + + await initAllNodesTarget(); // resets nodeSet to [] + }); + + it("should update AuthorizationPolicy if ipBlocks differ", async () => { + const authPol = { + apiVersion: "security.istio.io/v1beta1", + kind: "AuthorizationPolicy", + metadata: { + name: "example-authpol", + namespace: "default", + managedFields: [], + }, + spec: { + rules: [ + { + from: [{ source: { ipBlocks: ["0.0.0.0/0"] } }], + }, + ], + }, + } as AuthorizationPolicy; + + mockGetAuthPolicies.mockResolvedValue({ items: [authPol] }); + + await updateKubeNodesFromCreateUpdate({ + metadata: { name: "node1" }, + status: { addresses: [{ type: "InternalIP", address: "10.0.0.5" }] }, + } as MockNode); + + expect(authPol.spec!.rules![0].from![0].source!.ipBlocks).toEqual(["10.0.0.5/32"]); + expect(authPol.metadata!.managedFields).toBeUndefined(); + expect(mockApply).toHaveBeenCalled(); + }); + + it("should not update AuthorizationPolicy if ipBlocks match", async () => { + const authPol = { + apiVersion: "security.istio.io/v1beta1", + kind: "AuthorizationPolicy", + metadata: { + name: "authpol-match", + namespace: "default", + managedFields: [], + }, + spec: { + rules: [ + { + from: [{ source: { ipBlocks: ["10.0.0.6/32"] } }], + }, + ], + }, + } as AuthorizationPolicy; + + mockGetAuthPolicies.mockResolvedValue({ items: [authPol] }); + + await updateKubeNodesFromCreateUpdate({ + metadata: { name: "node2" }, + status: { addresses: [{ type: "InternalIP", address: "10.0.0.6" }] }, + } as MockNode); + + expect(mockApply).not.toHaveBeenCalled(); + }); + + it("should create 'from' field if missing", async () => { + const authPol = { + apiVersion: "security.istio.io/v1beta1", + kind: "AuthorizationPolicy", + metadata: { + name: "authpol-nofrom", + namespace: "default", + managedFields: [], + }, + spec: { + rules: [{}], + }, + } as AuthorizationPolicy; + + mockGetAuthPolicies.mockResolvedValue({ items: [authPol] }); + + await updateKubeNodesFromCreateUpdate({ + metadata: { name: "node3" }, + status: { addresses: [{ type: "InternalIP", address: "10.0.0.7" }] }, + } as MockNode); + + expect(authPol.spec!.rules![0].from?.[0]?.source?.ipBlocks).toEqual(["10.0.0.7/32"]); + expect(mockApply).toHaveBeenCalled(); + }); + + it("should skip policies missing rules", async () => { + const authPol = { + apiVersion: "security.istio.io/v1beta1", + kind: "AuthorizationPolicy", + metadata: { + name: "authpol-norules", + namespace: "default", + }, + spec: {}, + } as AuthorizationPolicy; + + mockGetAuthPolicies.mockResolvedValue({ items: [authPol] }); + + await updateKubeNodesAuthorizationPolicies(); + + expect(mockApply).not.toHaveBeenCalled(); + }); +}); + describe("kubeNodes module", () => { const mockNodeList = { items: [ diff --git a/src/pepr/operator/controllers/network/generators/kubeNodes.ts b/src/pepr/operator/controllers/network/generators/kubeNodes.ts index ba5c16529c..164ca3ecdb 100644 --- a/src/pepr/operator/controllers/network/generators/kubeNodes.ts +++ b/src/pepr/operator/controllers/network/generators/kubeNodes.ts @@ -3,15 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Defense-Unicorns-Commercial */ -import { KubernetesListObject } from "@kubernetes/client-node"; -import { V1NetworkPolicyPeer, V1NodeAddress } from "@kubernetes/client-node"; +import { KubernetesListObject, V1NetworkPolicyPeer, V1NodeAddress } from "@kubernetes/client-node"; import { K8s, kind, R } from "pepr"; +import { UDSConfig } from "../../../../config"; import { Component, setupLogger } from "../../../../logger"; import { RemoteGenerated } from "../../../crd"; -import { anywhere } from "./anywhere"; -import { UDSConfig } from "../../../../config"; +import { AuthorizationPolicy } from "../../../crd/generated/istio/authorizationpolicy-v1beta1"; import { retryWithDelay } from "../../utils"; +import { anywhere } from "./anywhere"; const log = setupLogger(Component.OPERATOR_GENERATORS); @@ -30,6 +30,7 @@ export async function initAllNodesTarget() { nodeSet.add(nodeCidr); } await updateKubeNodesNetworkPolicies(); + await updateKubeNodesAuthorizationPolicies(); return; } @@ -42,6 +43,7 @@ export async function initAllNodesTarget() { if (ip) nodeSet.add(ip); } await updateKubeNodesNetworkPolicies(); + await updateKubeNodesAuthorizationPolicies(); } catch (err) { log.error("error fetching node IPs:", err); } @@ -68,6 +70,7 @@ export async function updateKubeNodesFromCreateUpdate(node: kind.Node) { if (ip) nodeSet.add(ip); await updateKubeNodesNetworkPolicies(); + await updateKubeNodesAuthorizationPolicies(); } /** @@ -79,6 +82,7 @@ export async function updateKubeNodesFromDelete(node: kind.Node) { if (ip) nodeSet.delete(ip); await updateKubeNodesNetworkPolicies(); + await updateKubeNodesAuthorizationPolicies(); } /** @@ -148,6 +152,82 @@ export async function updateKubeNodesNetworkPolicies() { } } +/** + * Updates the AuthorizationPolicies for KubeNodes. + * + * This function rebuilds the current set of node peers from the in-memory node set, + * extracts their CIDR strings, and then queries for all AuthorizationPolicies that are labeled + * with "uds/generated" equal to RemoteGenerated.KubeNodes. For each matching policy, it checks + * whether the current IP blocks in the policy's "from" source match the newly computed IP blocks. + * If they differ, the policy is updated (with managedFields cleared to avoid server-side apply issues) + * and then re-applied. + * + * @returns {Promise} A promise that resolves once the update process is complete. + */ +export async function updateKubeNodesAuthorizationPolicies(): Promise { + // Build the current set of node peers from nodeSet. + const newPeers = buildNodePolicies([...nodeSet]); + // Extract CIDR strings from the new peers. + const newIpBlocks = newPeers + .map(peer => peer.ipBlock?.cidr) + .filter((cidr): cidr is string => typeof cidr === "string"); + + const authPols = await K8s(AuthorizationPolicy) + .WithLabel("uds/generated", RemoteGenerated.KubeNodes) + .Get(); + + if (authPols.items.length > 0) { + const summary = authPols.items + .map(pol => `name: ${pol.metadata?.name}, namespace: ${pol.metadata?.namespace}`) + .join(" | "); + log.trace(`Fetched ${authPols.items.length} AuthorizationPolicies: ${summary}`); + } + + for (const pol of authPols.items) { + // Ensure the policy has rules. + if (!pol.spec || !pol.spec.rules || pol.spec.rules.length === 0) { + log.warn( + `AuthorizationPolicy ${pol.metadata?.namespace}/${pol.metadata?.name} is missing rules.`, + ); + continue; + } + + let updateRequired = false; + const rule = pol.spec.rules[0]; + + // Check if a "from" entry exists with ipBlocks. + if (rule.from && rule.from.length > 0 && rule.from[0].source?.ipBlocks) { + const oldIpBlocks = rule.from[0].source.ipBlocks; + if (!R.equals(oldIpBlocks, newIpBlocks)) { + rule.from[0].source.ipBlocks = newIpBlocks; + updateRequired = true; + } + } else { + // Otherwise, create a "from" entry. + rule.from = [{ source: { ipBlocks: newIpBlocks } }]; + updateRequired = true; + } + + if (updateRequired) { + // Clear managedFields to avoid server-side apply issues. + if (pol.metadata) { + pol.metadata.managedFields = undefined; + } + try { + await K8s(AuthorizationPolicy).Apply(pol, { force: true }); + log.debug( + `Updated KubeNodes AuthorizationPolicy ${pol.metadata?.namespace}/${pol.metadata?.name}`, + ); + } catch (err) { + log.error( + err, + `Failed to update AuthorizationPolicy ${pol.metadata?.namespace}/${pol.metadata?.name}`, + ); + } + } + } +} + /** * Build V1NetworkPolicyPeer array from a list of node IPs. */ diff --git a/src/pepr/operator/controllers/utils.ts b/src/pepr/operator/controllers/utils.ts index 0a0559301b..1bc927de49 100644 --- a/src/pepr/operator/controllers/utils.ts +++ b/src/pepr/operator/controllers/utils.ts @@ -55,6 +55,7 @@ export function getOwnerRef(cr: GenericKind): V1OwnerReference[] { * @param {string} pkgName - The package name label to filter resources. * @param {T} kind - The Kubernetes resource kind to purge. * @param {Logger} log - Logger instance for logging debug messages. + * @param {Record} [additionalLabels] - Optional additional label filters to further narrow down the resources to purge. * @returns {Promise} - A promise that resolves when the operation is complete. */ export async function purgeOrphans( @@ -63,8 +64,17 @@ export async function purgeOrphans( pkgName: string, kind: T, log: Logger, + additionalLabels?: Record | undefined, ) { - const resources = await K8s(kind).InNamespace(namespace).WithLabel("uds/package", pkgName).Get(); + let query = K8s(kind).InNamespace(namespace).WithLabel("uds/package", pkgName); + + if (additionalLabels) { + for (const [key, value] of Object.entries(additionalLabels)) { + query = query.WithLabel(key, value); + } + } + + const resources = await query.Get(); for (const resource of resources.items) { if (resource.metadata?.labels?.["uds/generation"] !== generation) { diff --git a/src/pepr/operator/crd/generated/package-v1alpha1.ts b/src/pepr/operator/crd/generated/package-v1alpha1.ts index 4291e13f3e..2ccb0d9e55 100644 --- a/src/pepr/operator/crd/generated/package-v1alpha1.ts +++ b/src/pepr/operator/crd/generated/package-v1alpha1.ts @@ -185,6 +185,11 @@ export interface Allow { * The remote pod selector labels to allow traffic to/from */ remoteSelector?: { [key: string]: string }; + /** + * The remote service account to restrict incoming traffic from within the remote + * namespace. Only valid for Ingress rules. + */ + remoteServiceAccount?: string; /** * Labels to match pods in the namespace to apply the policy to. Leave empty to apply to all * pods in the namespace @@ -722,6 +727,7 @@ export interface ProtocolMapper { } export interface StatusObject { + authorizationPolicyCount?: number; authserviceClients?: string[]; /** * Status conditions following Kubernetes-style conventions diff --git a/src/pepr/operator/crd/sources/package/v1alpha1.ts b/src/pepr/operator/crd/sources/package/v1alpha1.ts index 880603fece..a244d7fc7b 100644 --- a/src/pepr/operator/crd/sources/package/v1alpha1.ts +++ b/src/pepr/operator/crd/sources/package/v1alpha1.ts @@ -108,6 +108,12 @@ const allow = { type: "number", }, }, + remoteServiceAccount: { + description: + "The remote service account to restrict incoming traffic from within the remote namespace. \ + Only valid for Ingress rules.", + type: "string", + }, // Deprecated fields podLabels: { description: "Deprecated: use selector", @@ -479,6 +485,12 @@ export const v1alpha1: V1CustomResourceDefinitionVersion = { description: "The number of network policies created by the package", jsonPath: ".status.networkPolicyCount", }, + { + name: "Authorization Policies", + type: "integer", + description: "The number of authorization policies created by the package", + jsonPath: ".status.authorizationPolicyCount", + }, { name: "Age", type: "date", @@ -570,6 +582,9 @@ export const v1alpha1: V1CustomResourceDefinitionVersion = { networkPolicyCount: { type: "integer", }, + authorizationPolicyCount: { + type: "integer", + }, retryAttempt: { type: "integer", nullable: true, diff --git a/src/pepr/operator/reconcilers/package-reconciler.ts b/src/pepr/operator/reconcilers/package-reconciler.ts index d8ce9e26d0..47e9686f46 100644 --- a/src/pepr/operator/reconcilers/package-reconciler.ts +++ b/src/pepr/operator/reconcilers/package-reconciler.ts @@ -16,6 +16,7 @@ import { keycloak, purgeSSOClients } from "../controllers/keycloak/client-sync"; import { Client } from "../controllers/keycloak/types"; import { podMonitor } from "../controllers/monitoring/pod-monitor"; import { serviceMonitor } from "../controllers/monitoring/service-monitor"; +import { generateAuthorizationPolicies } from "../controllers/network/authorizationPolicies"; import { networkPolicies } from "../controllers/network/policies"; import { retryWithDelay } from "../controllers/utils"; import { Phase, UDSPackage } from "../crd"; @@ -71,6 +72,8 @@ export async function packageReconciler(pkg: UDSPackage) { const netPol = await networkPolicies(pkg, namespace!); + const authPol = await generateAuthorizationPolicies(pkg, namespace!); + let endpoints: string[] = []; // Update the namespace to ensure the istio-injection label is set await enableInjection(pkg); @@ -105,6 +108,7 @@ export async function packageReconciler(pkg: UDSPackage) { endpoints, monitors, networkPolicyCount: netPol.length, + authorizationPolicyCount: authPol.length + authserviceClients.length * 2, observedGeneration: metadata.generation, retryAttempt: 0, // todo: make this nullable when kfc generates the type });