Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/dev/gcp-range-cell-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion shifter/engine/provisioner/gcp_range_cell_firewall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
3 changes: 2 additions & 1 deletion shifter/engine/provisioner/raes_gce_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion shifter/engine/provisioner/raes_gcp_destroy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions shifter/engine/provisioner/tests/test_raes_gce_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions shifter/engine/provisioner/tests/test_raes_gcp_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 19 additions & 0 deletions shifter/engine/provisioner/tests/test_raes_gcp_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions shifter/shifter_platform/cms/api/runtime_plugin_packs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions shifter/shifter_platform/frontend/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<AdapterPackBindings organization="org-1" adapters={[adapter]} />);
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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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 })}>
<option value="gcp">Google Cloud</option><option value="aws">AWS</option>
</select>
</div>
Expand Down Expand Up @@ -228,6 +230,11 @@ function TargetImageProfile({ name, value, onChange }: Readonly<{
onChange={(event) => update({ disk_size_gb: event.target.value ? Number(event.target.value) : null })} /></div>
<ProfileInput id={`plugin-disk-type-${name}`} label={`Disk type for ${name}`} value={value.disk_type}
onChange={(disk_type) => update({ disk_type })} />
{value.provider === "gcp" ? <div className="flex items-center gap-2 sm:col-span-2">
<input id={"plugin-public-web-egress-" + name} type="checkbox" checked={value.allow_public_web_egress ?? false}
onChange={(event) => update({ allow_public_web_egress: event.target.checked })} />
<Label htmlFor={"plugin-public-web-egress-" + name}>Allow participant public web access (TCP 80/443)</Label>
</div> : null}
{value.provider === "gcp" && value.image_kind === "image" ? <div className="space-y-1">
<Label htmlFor={`plugin-bootstrap-capability-${name}`}>Bootstrap capability for {name}</Label>
<select id={`plugin-bootstrap-capability-${name}`} value={value.bootstrap_capability}
Expand Down
4 changes: 4 additions & 0 deletions shifter/shifter_platform/openapi/v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -26918,6 +26918,10 @@
"default": "",
"maxLength": 100
},
"allow_public_web_egress": {
"type": "boolean",
"default": false
},
"bootstrap_capability": {
"type": "string",
"default": "standard",
Expand Down
3 changes: 3 additions & 0 deletions shifter/shifter_platform/shared/runtime_plugin_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class RuntimeTargetImageProfile(ClosedModel):
machine_type: Annotated[str, Field(max_length=100)] = ""
disk_size_gb: Annotated[int, Field(strict=True, ge=1, le=16_384)] | None = None
disk_type: Annotated[str, Field(max_length=100)] = ""
allow_public_web_egress: bool = Field(default=False, strict=True)
bootstrap_capability: Annotated[str, Field(max_length=64)] = "standard"
management_ssh_username: Annotated[str, Field(max_length=32)] = ""
management_ssh_port: Annotated[int, Field(strict=True, ge=1, le=65_535)] = 22
Expand Down Expand Up @@ -94,6 +95,8 @@ def _validate_aws_image_profile(profile: RuntimeTargetImageProfile) -> None:
raise ValueError("AWS image profiles do not support machine-host fields")
if profile.disk_type and profile.disk_type not in {"gp2", "gp3"}:
raise ValueError("AWS image profile disk type is unsupported")
if profile.allow_public_web_egress:
raise ValueError("AWS image profiles do not support public web egress")


def _validate_gcp_image_profile(profile: RuntimeTargetImageProfile) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def test_admin_binds_a_provider_image_without_hosting_access(pack_api):
"participant_username": "student",
"participant_readiness_contract": "participant-readiness/v1",
"participant_readiness_manifest_sha256": "a" * 64,
"allow_public_web_egress": True,
}
},
},
Expand All @@ -92,6 +93,7 @@ def test_admin_binds_a_provider_image_without_hosting_access(pack_api):
)
assert response.status_code == 200
assert response.json()["bindings"]["image_profiles"]["server"]["provider"] == "gcp"
assert response.json()["bindings"]["image_profiles"]["server"]["allow_public_web_egress"] is True


def test_admin_binds_a_prepromoted_directory_image(pack_api):
Expand Down
Loading
Loading