diff --git a/docs/dev/gcp-range-cell-deploy.md b/docs/dev/gcp-range-cell-deploy.md index 6d58bfbc5..ee7465870 100644 --- a/docs/dev/gcp-range-cell-deploy.md +++ b/docs/dev/gcp-range-cell-deploy.md @@ -209,6 +209,10 @@ require participant web research may set range-owned egress rule allowing only TCP 80/443 to public IPv4 through the shared VPC's Cloud NAT. The default is `false`; the per-range default-deny rule remains in place, and unrelated profiles receive no public egress allowance. +For an administrator-selected runtime-plugin image, set the same option on the +GCP target image profile in the pack assignment UI. It is pinned with that +assignment for new ranges; existing ranges keep their original egress posture. +AWS target profiles cannot use this GCP option. The value is a non-secret JSON object, limited to 32,768 bytes and 64 total entries. Profile classes and fields are closed. Logical keys must be lowercase diff --git a/shifter/engine/provisioner/gcp_range_cell_firewall.py b/shifter/engine/provisioner/gcp_range_cell_firewall.py index acf71aae8..02f2554f3 100644 --- a/shifter/engine/provisioner/gcp_range_cell_firewall.py +++ b/shifter/engine/provisioner/gcp_range_cell_firewall.py @@ -27,6 +27,12 @@ _UNIVERSAL_IPV4_CIDR = "0.0.0.0/0" + +def public_web_firewall_name(range_id: int) -> str: + """Return the stable optional web-lane name for reconstructive cleanup.""" + return _short_resource_name("shifter-r", range_id, "egress-web") + + # IANA special-use IPv4 space (RFC 6890 and friends) subtracted from the # universal range to compute the public-internet complement used as the VPN # ingress source list. These are protocol constants by definition; the S1313 @@ -299,7 +305,7 @@ def _egress_rules( # of rule precedence against the default deny. rules.append( { - "name": _short_resource_name("shifter-r", range_id, "egress-web"), + "name": public_web_firewall_name(range_id), "direction": "EGRESS", "priority": 1200, "target_tags": [range_tag], diff --git a/shifter/engine/provisioner/raes_gce_image.py b/shifter/engine/provisioner/raes_gce_image.py index add99a61b..c2367a84b 100644 --- a/shifter/engine/provisioner/raes_gce_image.py +++ b/shifter/engine/provisioner/raes_gce_image.py @@ -106,7 +106,7 @@ def resolve_gce_image_from_runtime_profile( """Realize the tenant-admin image selected with an adapter target binding.""" if profile.provider != "gcp": raise RaesGceImageError("adapter image profile provider does not match GCE realization") - return _profile( + resolved = _profile( node, ResolvedImage( image_ref=profile.image_ref, @@ -125,6 +125,7 @@ def resolve_gce_image_from_runtime_profile( domain_dns_name=profile.domain_dns_name, domain_netbios_name=profile.domain_netbios_name, ) + return replace(resolved, allow_public_web_egress=profile.allow_public_web_egress) def _resolve_base_os(node: RaesPlanNode, candidates: Sequence[dict[str, Any]]) -> GCERangeImageProfile: diff --git a/shifter/engine/provisioner/raes_gcp_destroy.py b/shifter/engine/provisioner/raes_gcp_destroy.py index 1f05183a6..f4bd11d15 100644 --- a/shifter/engine/provisioner/raes_gcp_destroy.py +++ b/shifter/engine/provisioner/raes_gcp_destroy.py @@ -19,6 +19,7 @@ from config import GCERangeCellConfig, GCERangeImageProfile, load_gce_range_cell_config from gcp_range_cell_clients import GCEClients, _build_clients +from gcp_range_cell_firewall import public_web_firewall_name from gcp_range_cell_model_broker import broker_firewall_name from gcp_range_cell_ops import _delete_resource from gcp_range_cell_types import InstancePlan, RangeCellPlan @@ -169,7 +170,10 @@ def _destroy_network_resources(plan: RangeCellPlan, clients: GCEClients) -> None router=router_nat["router_name"], ) - firewall_names = {rule["name"] for rule in plan["firewalls"]} | {broker_firewall_name(plan["range_id"])} + firewall_names = {rule["name"] for rule in plan["firewalls"]} | { + broker_firewall_name(plan["range_id"]), + public_web_firewall_name(plan["range_id"]), + } for firewall_name in sorted(firewall_names, reverse=True): _delete_resource( plan, diff --git a/shifter/engine/provisioner/tests/test_raes_gce_image.py b/shifter/engine/provisioner/tests/test_raes_gce_image.py index 6fa619ba3..57c33785d 100644 --- a/shifter/engine/provisioner/tests/test_raes_gce_image.py +++ b/shifter/engine/provisioner/tests/test_raes_gce_image.py @@ -54,10 +54,36 @@ def test_adapter_target_profile_is_a_first_class_image_source(self): participant_username="student", participant_readiness_contract="participant-readiness/v1", participant_readiness_manifest_sha256="a" * 64, + allow_public_web_egress=True, ) profile = resolve_gce_image_from_runtime_profile(_node(), runtime) assert profile.source_machine_image == runtime.image_ref assert profile.machine_type == "e2-standard-8" + assert profile.allow_public_web_egress is True + + def test_adapter_target_profile_defaults_to_no_public_web_egress(self): + runtime = RuntimeTargetImageProfile( + provider="gcp", + image_ref="projects/example/global/images/desktop-v1", + ) + profile = resolve_gce_image_from_runtime_profile(_node(), runtime) + assert profile.allow_public_web_egress is False + + def test_aws_adapter_profile_cannot_enable_gcp_public_web_egress(self): + with pytest.raises(ValueError, match="AWS image profiles do not support public web egress"): + RuntimeTargetImageProfile( + provider="aws", + image_ref="ami-0123456789abcdef0", + allow_public_web_egress=True, + ) + + def test_adapter_profile_requires_a_boolean_public_web_choice(self): + with pytest.raises(ValueError): + RuntimeTargetImageProfile( + provider="gcp", + image_ref="projects/example/global/images/desktop-v1", + allow_public_web_egress="true", + ) def test_adapter_target_profile_preserves_prepromoted_directory_contract(self): runtime = RuntimeTargetImageProfile( diff --git a/shifter/engine/provisioner/tests/test_raes_gcp_apply.py b/shifter/engine/provisioner/tests/test_raes_gcp_apply.py index 562895944..ec7e8ed1b 100644 --- a/shifter/engine/provisioner/tests/test_raes_gcp_apply.py +++ b/shifter/engine/provisioner/tests/test_raes_gcp_apply.py @@ -1070,6 +1070,17 @@ def test_destroy_reconstructively_deletes_service_firewall_by_same_name(self): class TestDestroy: + def test_reconstructive_destroy_sweeps_optional_public_web_firewall(self): + clients = _clients(exists=True) + secret_ops, _ = _secret_ops() + destroy_raes_range_cell( + "req-1", 7, _plan(), RaesGceDestroyOptions(config=_config(), clients=clients, secret_ops=secret_ops) + ) + + assert any( + call.kwargs.get("firewall") == "shifter-r-7-egress-web" for call in clients.firewalls.delete.call_args_list + ) + def test_deletes_instances_addresses_firewalls_subnets_network_and_secrets(self): clients = _clients(exists=True) secret_ops, secret_mocks = _secret_ops() diff --git a/shifter/engine/provisioner/tests/test_raes_gcp_plan.py b/shifter/engine/provisioner/tests/test_raes_gcp_plan.py index 3429fd33e..288df9778 100644 --- a/shifter/engine/provisioner/tests/test_raes_gcp_plan.py +++ b/shifter/engine/provisioner/tests/test_raes_gcp_plan.py @@ -14,6 +14,8 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) +from shared.runtime_plugin_binding import RuntimeTargetImageProfile + from config import ( GCE_BOOTSTRAP_PRECONFIGURED_MACHINE_HOST, GCE_PARTICIPANT_READINESS_CONTRACT_V1, @@ -22,6 +24,7 @@ ) from gcp_range_cell_types import GceEgressPolicy from raes_access import RealizedAccessBinding +from raes_gce_image import resolve_gce_image_from_runtime_profile from raes_gcp_firewall import node_tag from raes_gcp_plan import RaesGcePlanError, RaesGcePlanOptions, build_raes_range_cell_plan from raes_identity import RESERVED_MANAGEMENT_LOGIN @@ -563,6 +566,22 @@ def test_resolved_image_profile_controls_public_web_egress(self, allow_public_we if web_rules: assert web_rules[0]["allowed"] == [{"IPProtocol": "tcp", "ports": ["80", "443"]}] + def test_admin_bound_runtime_profile_can_enable_scoped_public_web_egress(self): + runtime = RuntimeTargetImageProfile( + provider="gcp", + image_ref="projects/example/global/images/workstation-v1", + allow_public_web_egress=True, + ) + plan = build_raes_range_cell_plan( + "req-1", + 7, + _plan((_node(),), (_network(),)), + lambda node: resolve_gce_image_from_runtime_profile(node, runtime), + _config(), + ) + web_rule = next(firewall for firewall in plan["firewalls"] if firewall["name"] == "shifter-r-7-egress-web") + assert web_rule["allowed"] == [{"IPProtocol": "tcp", "ports": ["80", "443"]}] + def test_zero_egress_overrides_a_web_permitting_profile(self): """A pinned `none` range opens no public-web egress lane, even if the profile would.""" profile = GCERangeImageProfile( diff --git a/shifter/shifter_platform/cms/api/runtime_plugin_packs.py b/shifter/shifter_platform/cms/api/runtime_plugin_packs.py index 6ae333253..02b01018b 100644 --- a/shifter/shifter_platform/cms/api/runtime_plugin_packs.py +++ b/shifter/shifter_platform/cms/api/runtime_plugin_packs.py @@ -34,6 +34,7 @@ class RuntimeTargetImageProfileSerializer(serializers.Serializer): machine_type = serializers.CharField(max_length=100, allow_blank=True, default="") disk_size_gb = serializers.IntegerField(min_value=1, max_value=16_384, allow_null=True, default=None) disk_type = serializers.CharField(max_length=100, allow_blank=True, default="") + allow_public_web_egress = serializers.BooleanField(default=False) bootstrap_capability = serializers.CharField(max_length=64, default="standard") management_ssh_username = serializers.CharField(max_length=32, allow_blank=True, default="") management_ssh_port = serializers.IntegerField(min_value=1, max_value=65_535, default=22) diff --git a/shifter/shifter_platform/engine/services/_runtime_plugin_bindings.py b/shifter/shifter_platform/engine/services/_runtime_plugin_bindings.py index 5fc2fc75f..6dee02994 100644 --- a/shifter/shifter_platform/engine/services/_runtime_plugin_bindings.py +++ b/shifter/shifter_platform/engine/services/_runtime_plugin_bindings.py @@ -7,7 +7,7 @@ from uuid import UUID from django.db import transaction -from shifter_adapter_sdk.runtime import PluginManifest +from shifter_adapter_sdk.runtime import PluginManifest, canonical_digest from shared.audit import AuditAction, AuditActorType, AuditEntityType, AuditEvent, RequestAudit, audit_log from shared.exceptions import ValidationError @@ -217,7 +217,9 @@ def retained_runtime_plugin_pin(target: Range) -> RuntimePluginPin | None: return None try: pin = RuntimePluginPin.model_validate(row.pin) - if pin.digest != row.pin_digest or pin.installation_id != row.installation_id: + # Check the exact retained bytes, not a re-serialized model: adding a + # defaulted binding field must not make pre-upgrade pins undeletable. + if canonical_digest(row.pin) != row.pin_digest or pin.installation_id != row.installation_id: raise ValueError("Stored pin identity mismatch") pin.bindings.validate_plan(target.range_config) if target.range_backend: diff --git a/shifter/shifter_platform/frontend/src/api/schema.d.ts b/shifter/shifter_platform/frontend/src/api/schema.d.ts index ff803f3c4..0feef61b1 100644 --- a/shifter/shifter_platform/frontend/src/api/schema.d.ts +++ b/shifter/shifter_platform/frontend/src/api/schema.d.ts @@ -6850,6 +6850,8 @@ export interface components { disk_size_gb?: number | null; /** @default */ disk_type: string; + /** @default false */ + allow_public_web_egress: boolean; /** @default standard */ bootstrap_capability: string; /** @default */ diff --git a/shifter/shifter_platform/frontend/src/features/administer/AdapterPackBindings.test.tsx b/shifter/shifter_platform/frontend/src/features/administer/AdapterPackBindings.test.tsx index 9a5c12b80..dc5ab45e8 100644 --- a/shifter/shifter_platform/frontend/src/features/administer/AdapterPackBindings.test.tsx +++ b/shifter/shifter_platform/frontend/src/features/administer/AdapterPackBindings.test.tsx @@ -66,7 +66,35 @@ describe("pack adapter assignments", () => { fireEvent.change(screen.getByLabelText("Participant container for server"), { target: { value: "participant-desktop" } }); fireEvent.change(screen.getByLabelText("Participant username for server"), { target: { value: "student" } }); fireEvent.change(screen.getByLabelText("Readiness manifest SHA-256 for server"), { target: { value: "a".repeat(64) } }); + fireEvent.click(screen.getByLabelText("Allow participant public web access (TCP 80/443)")); expect(screen.getByRole("button", { name: "Review assignment" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Review assignment" })); + fireEvent.click(screen.getByRole("button", { name: "Save assignment" })); + await waitFor(() => { + const body = mockApi.mock.calls.find(([, options]) => options?.method === "POST")?.[1]?.body as + | { bindings: { image_profiles: { server: { allow_public_web_egress: boolean } } } } + | undefined; + expect(body?.bindings.image_profiles.server.allow_public_web_egress).toBe(true); + }); + }); + + it("clears the GCP web option when switching an assignment to AWS", async () => { + renderRoute(); + await fillAssignment(); + fireEvent.click(screen.getByLabelText("Use an administrator-selected provider image for server")); + fireEvent.click(screen.getByLabelText("Allow participant public web access (TCP 80/443)")); + fireEvent.change(screen.getByLabelText("Provider for server"), { target: { value: "aws" } }); + expect(screen.queryByLabelText("Allow participant public web access (TCP 80/443)")).not.toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Image reference for server"), { target: { value: "ami-12345678" } }); + expect(screen.getByRole("button", { name: "Review assignment" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Review assignment" })); + fireEvent.click(screen.getByRole("button", { name: "Save assignment" })); + await waitFor(() => { + const body = mockApi.mock.calls.find(([, options]) => options?.method === "POST")?.[1]?.body as + | { bindings: { image_profiles: { server: { provider: string; allow_public_web_egress: boolean } } } } + | undefined; + expect(body?.bindings.image_profiles.server).toMatchObject({ provider: "aws", allow_public_web_egress: false }); + }); }); it("lets an administrator bind a prepromoted directory image", async () => { diff --git a/shifter/shifter_platform/frontend/src/features/administer/AdapterPackBindings.tsx b/shifter/shifter_platform/frontend/src/features/administer/AdapterPackBindings.tsx index ecc8f8218..aace7372a 100644 --- a/shifter/shifter_platform/frontend/src/features/administer/AdapterPackBindings.tsx +++ b/shifter/shifter_platform/frontend/src/features/administer/AdapterPackBindings.tsx @@ -76,6 +76,7 @@ const emptyImageProfile = (): AdapterTargetImageProfile => ({ machine_type: "", disk_size_gb: null, disk_type: "", + allow_public_web_egress: false, bootstrap_capability: "standard", management_ssh_username: "", management_ssh_port: 22, @@ -89,7 +90,8 @@ const emptyImageProfile = (): AdapterTargetImageProfile => ({ function validImageProfile(profile: AdapterTargetImageProfile): boolean { if (!profile.image_ref || profile.management_ssh_port < 1 || profile.management_ssh_port > 65535) return false; - if (profile.provider === "aws") return profile.image_kind === "image" && /^ami-(?:[0-9a-f]{8}|[0-9a-f]{17})$/.test(profile.image_ref); + if (profile.provider === "aws") return !profile.allow_public_web_egress + && profile.image_kind === "image" && /^ami-(?:[0-9a-f]{8}|[0-9a-f]{17})$/.test(profile.image_ref); if (profile.image_kind === "image") { if (profile.bootstrap_capability === "standard") return !profile.domain_dns_name && !profile.domain_netbios_name; return profile.bootstrap_capability === "prepromoted-domain-controller" @@ -197,7 +199,7 @@ function TargetImageProfile({ name, value, onChange }: Readonly<{ onChange={(event) => update({ provider: event.target.value as "gcp" | "aws", image_kind: "image", bootstrap_capability: "standard", participant_container_name: "", participant_username: "", participant_readiness_contract: "", participant_readiness_manifest_sha256: "", - domain_dns_name: "", domain_netbios_name: "" })}> + domain_dns_name: "", domain_netbios_name: "", allow_public_web_egress: false })}> @@ -228,6 +230,11 @@ function TargetImageProfile({ name, value, onChange }: Readonly<{ onChange={(event) => update({ disk_size_gb: event.target.value ? Number(event.target.value) : null })} /> update({ disk_type })} /> + {value.provider === "gcp" ?
+ update({ allow_public_web_egress: event.target.checked })} /> + +
: null} {value.provider === "gcp" && value.image_kind === "image" ?