OCI Bastion Service: Zero-Trust SSH Access to Private Resources with Terraform

The traditional approach to accessing private compute resources is a jump box: a public-facing instance in a DMZ subnet that engineers SSH into first, then hop from there to private instances. Jump boxes become operational debt quickly. They need OS patches, key rotation, monitoring, and someone responsible for them. They sit idle 95% of the time burning compute cost. And when they are compromised, they become a pivot point into your entire private network.

OCI Bastion is a managed service that replaces the jump box. It provides time-limited, audited SSH access to private resources without any publicly reachable instance. Sessions are created through the OCI API, authenticated through IAM, and automatically terminated when they expire. No persistent public endpoint. No SSH keys on a shared host. No manual session cleanup.

This post covers deploying OCI Bastion with Terraform, creating managed SSH sessions, port forwarding to private databases, dynamic port forwarding for broader access, and monitoring sessions through OCI Audit and Logging.

How OCI Bastion Works

OCI Bastion creates an ephemeral SSH proxy endpoint on demand. When you request a session, OCI provisions a temporary SSH endpoint scoped to the target resource. The session has a maximum lifetime you define at creation time. When it expires, the endpoint is torn down. No residual access remains.

Three session types are supported. Managed SSH sessions give you a full interactive shell to a compute instance without needing a public IP on the instance. Port forwarding sessions tunnel a specific port from a private resource to your local machine, useful for database connections. Dynamic port forwarding sessions create a SOCKS5 proxy through which you can reach any private resource in the VCN.

Step 1: IAM Policy

resource "oci_identity_policy" "bastion_policy" {
  compartment_id = var.compartment_id
  name           = "bastion-access-policy"
  description    = "Permissions to create and manage Bastion sessions"

  statements = [
    # Operators can manage Bastion resources
    "Allow group ${var.ops_group_name} to manage bastion-family in compartment id ${var.compartment_id}",

    # Developers can only create sessions, not manage the Bastion itself
    "Allow group ${var.dev_group_name} to use bastion in compartment id ${var.compartment_id}",
    "Allow group ${var.dev_group_name} to manage bastion-session in compartment id ${var.compartment_id}",

    # Required for session creation - read target instance details
    "Allow group ${var.dev_group_name} to read instance-family in compartment id ${var.compartment_id}",
    "Allow group ${var.dev_group_name} to read virtual-network-family in compartment id ${var.compartment_id}"
  ]
}

The separation between manage bastion-family for operators and use bastion for developers is important. Developers can create and terminate their own sessions but cannot modify the Bastion configuration, change the allowed CIDR list, or delete the Bastion itself.

Step 2: Deploy the Bastion

resource "oci_bastion_bastion" "private_access" {
  compartment_id       = var.compartment_id
  bastion_type         = "STANDARD"
  name                 = "production-private-bastion"
  target_subnet_id     = var.private_subnet_id

  # Only allow session creation from these source IPs
  client_cidr_block_allow_list = [
    var.corporate_vpn_cidr,
    var.office_ip_cidr
  ]

  # Maximum session lifetime in seconds - 3 hours
  max_session_ttl_in_seconds = 10800

  phone_book_entry = var.ops_team_email

  defined_tags = {
    "Operations.Environment" = "production"
    "Operations.ManagedBy"   = "terraform"
  }
}

output "bastion_id" {
  value = oci_bastion_bastion.private_access.id
}

output "bastion_endpoint" {
  value = oci_bastion_bastion.private_access.private_endpoint_ip_address
}

The client_cidr_block_allow_list restricts which source IPs can create Bastion sessions. Set this to your corporate VPN range and office IP ranges only. An engineer working from a home network or coffee shop without VPN cannot create a session. This is an important control that most people skip when setting up Bastion for the first time.

The max_session_ttl_in_seconds = 10800 caps sessions at 3 hours regardless of what the session creator requests. Set this based on your operational requirements. 1 hour is sufficient for most maintenance tasks. 8 hours covers a full working day for extended debugging sessions.

Step 3: Target Instance NSG Rule

The private instance must accept SSH connections from the Bastion subnet. Add an ingress rule to the instance NSG allowing port 22 from the Bastion private endpoint IP.

resource "oci_core_network_security_group_security_rule" "allow_bastion_ssh" {
  network_security_group_id = var.instance_nsg_id
  direction                 = "INGRESS"
  protocol                  = "6"
  source                    = "${oci_bastion_bastion.private_access.private_endpoint_ip_address}/32"
  source_type               = "CIDR_BLOCK"

  tcp_options {
    destination_port_range {
      min = 22
      max = 22
    }
  }

  description = "Allow SSH from OCI Bastion private endpoint only"
}

Step 4: Creating a Managed SSH Session

A managed SSH session gives you a shell on a private instance. OCI injects a temporary SSH public key into the instance for the session duration using the Oracle Cloud Agent. No pre-existing SSH key pair is required on the instance.

# Generate a temporary key pair for this session
ssh-keygen -t ed25519 -f /tmp/bastion-session-key -N "" -C "bastion-session-$(date +%Y%m%d%H%M%S)"

# Create the managed SSH session via OCI CLI
oci bastion session create-managed-ssh \
  --bastion-id ocid1.bastion.oc1.me-jeddah-1.your-bastion-ocid \
  --display-name "ops-debug-session-$(date +%Y%m%d)" \
  --ssh-public-key-file /tmp/bastion-session-key.pub \
  --target-os-username opc \
  --target-resource-id ocid1.instance.oc1.me-jeddah-1.your-instance-ocid \
  --target-resource-port 22 \
  --session-ttl 3600

# Wait for the session to become active
oci bastion session get \
  --session-id <session-ocid> \
  --query 'data."lifecycle-state"'

# Once ACTIVE, get the SSH command
oci bastion session get \
  --session-id <session-ocid> \
  --query 'data."ssh-metadata".command'

The output of the last command gives you the full SSH command with the correct proxy configuration pre-populated. It looks like this:

ssh -i <privateKey> \
  -o StrictHostKeyChecking=no \
  -o UserKnownHostsFile=/dev/null \
  -p 22 \
  -o ProxyCommand='ssh -i <privateKey> -W %h:%p -p 22 ocid1.bastionsession.oc1...@host.bastion.me-jeddah-1.oci.oraclecloud.com' \
  opc@<private-instance-ip>

Step 5: Port Forwarding Session for Database Access

Port forwarding sessions tunnel a specific port from a private resource to your local machine. Use this to connect database clients like SQL Developer, DBeaver, or psql directly to a private database without an SSH shell.

# Create a port forwarding session targeting the ADB private endpoint
oci bastion session create-port-forwarding \
  --bastion-id ocid1.bastion.oc1.me-jeddah-1.your-bastion-ocid \
  --display-name "adb-port-forward-$(date +%Y%m%d)" \
  --ssh-public-key-file /tmp/bastion-session-key.pub \
  --target-private-ip <adb-private-endpoint-ip> \
  --target-port 1522 \
  --session-ttl 3600

# Once the session is ACTIVE, open the tunnel
ssh -i /tmp/bastion-session-key \
  -N \
  -L 1522:<adb-private-endpoint-ip>:1522 \
  -p 22 \
  ocid1.bastionsession.oc1...@host.bastion.me-jeddah-1.oci.oraclecloud.com

# Now connect your database client to localhost:1522
# The connection tunnels through Bastion to the private ADB endpoint

The -N flag runs SSH without executing a remote command, keeping the tunnel open. Run this in a terminal window and leave it open while your database client is connected. When you close the terminal, the tunnel closes.

Step 6: Automate Session Creation with Python

import oci
import subprocess
import time
import logging
import os
from pathlib import Path

logger = logging.getLogger(__name__)

class BastionSessionManager:
    def __init__(self, bastion_id: str, region: str):
        self.bastion_id = bastion_id
        config = oci.config.from_file()
        config["region"] = region
        self.client      = oci.bastion.BastionClient(config)

    def create_port_forward_session(
        self,
        target_ip: str,
        target_port: int,
        local_port: int,
        public_key_path: str,
        ttl_seconds: int = 3600,
        display_name: str = "automated-session"
    ) -> str:
        with open(public_key_path) as f:
            public_key = f.read().strip()

        response = self.client.create_session(
            create_session_details=oci.bastion.models.CreateSessionDetails(
                bastion_id=self.bastion_id,
                display_name=display_name,
                session_ttl_in_seconds=ttl_seconds,
                key_details=oci.bastion.models.PublicKeyDetails(
                    public_key_content=public_key
                ),
                target_resource_details=oci.bastion.models.CreatePortForwardingSessionTargetResourceDetails(
                    session_type="PORT_FORWARDING",
                    target_resource_private_ip_address=target_ip,
                    target_resource_port=target_port
                )
            )
        )

        session_id = response.data.id
        logger.info(f"Session {session_id} created, waiting for ACTIVE state")

        # Wait for the session to become active
        for attempt in range(30):
            session = self.client.get_session(session_id).data
            if session.lifecycle_state == "ACTIVE":
                logger.info(f"Session {session_id} is ACTIVE")
                return session_id
            if session.lifecycle_state in ("FAILED", "DELETED"):
                raise RuntimeError(f"Session entered {session.lifecycle_state} state")
            time.sleep(5)

        raise TimeoutError("Session did not become ACTIVE within 150 seconds")

    def open_tunnel(
        self,
        session_id: str,
        target_ip: str,
        target_port: int,
        local_port: int,
        private_key_path: str,
        bastion_endpoint: str
    ) -> subprocess.Popen:
        cmd = [
            "ssh",
            "-i", private_key_path,
            "-N",
            "-o", "StrictHostKeyChecking=no",
            "-o", "UserKnownHostsFile=/dev/null",
            "-L", f"{local_port}:{target_ip}:{target_port}",
            "-p", "22",
            f"{session_id}@{bastion_endpoint}"
        ]

        process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        time.sleep(3)

        if process.poll() is not None:
            raise RuntimeError(f"SSH tunnel process exited immediately with code {process.returncode}")

        logger.info(f"Tunnel open: localhost:{local_port} to {target_ip}:{target_port}")
        return process

    def terminate_session(self, session_id: str):
        self.client.delete_session(session_id)
        logger.info(f"Session {session_id} terminated")

Step 7: Session Monitoring and Auditing

Every Bastion session creation, connection, and termination is captured in OCI Audit automatically. No additional configuration is needed for audit logging. The audit records include the IAM user who created the session, the source IP, the target resource, and the session start and end times.

Enable dedicated logging for Bastion events to keep them separate from general audit logs:

resource "oci_logging_log" "bastion_session_log" {
  display_name = "bastion-session-activity"
  log_group_id = var.security_log_group_id
  log_type     = "SERVICE"

  configuration {
    source {
      category    = "all"
      resource    = oci_bastion_bastion.private_access.id
      service     = "bastion"
      source_type = "OCISERVICE"
    }
    compartment_id = var.compartment_id
  }

  retention_duration = 90
  is_enabled         = true
}

resource "oci_monitoring_alarm" "bastion_session_created" {
  compartment_id        = var.compartment_id
  display_name          = "bastion-session-created"
  is_enabled            = true
  metric_compartment_id = var.compartment_id
  namespace             = "oci_bastion"
  query                 = "ActiveSessions[5m]{bastionId = '${oci_bastion_bastion.private_access.id}'}.max() > 0"
  severity              = "INFO"
  pending_duration      = "PT1M"
  destinations          = [var.security_notification_topic_id]
  body                  = "A Bastion session is active on the production Bastion. Review session details in OCI Audit if this was not expected."
}

Step 8: Listing and Terminating Active Sessions

# List all active sessions
oci bastion session list \
  --bastion-id <bastion-ocid> \
  --session-lifecycle-state ACTIVE \
  --query 'data[*].{id:id, name:"display-name", user:"created-by", target:"target-resource-details"."target-resource-private-ip-address", expires:"time-expires"}' \
  --output table

# Terminate a specific session immediately
oci bastion session delete \
  --session-id <session-ocid> \
  --force

# Terminate all active sessions on a Bastion (emergency lockdown)
oci bastion session list \
  --bastion-id <bastion-ocid> \
  --session-lifecycle-state ACTIVE \
  --query 'data[*].id' \
  --raw-output \
  | jq -r '.[]' \
  | xargs -I {} oci bastion session delete --session-id {} --force

The emergency lockdown command terminates every active session simultaneously. Use this when you suspect a compromised credential or unauthorized access. Because Bastion sessions are ephemeral with no persistent connection state, terminating a session immediately closes the SSH connection on the engineer’s terminal.

Operational Notes

Bastion requires the Oracle Cloud Agent to be running on the target instance for managed SSH sessions. The agent is pre-installed on Oracle Linux images. For custom images, install and enable it manually. Without the agent, managed SSH sessions fail because OCI cannot inject the temporary public key into the instance’s authorized_keys file.

Port forwarding sessions do not require the Oracle Cloud Agent. They only need a TCP path from the Bastion endpoint to the target IP and port. Use port forwarding sessions for databases, internal APIs, and any resource that does not run SSH.

Keep session TTLs short. A 1-hour TTL is appropriate for most tasks. Long-lived sessions increase the window during which a stolen session credential can be used. If a task genuinely requires extended access, create a new session rather than setting an 8-hour TTL on the first one. Each new session generates a fresh audit record that documents the continued access intent.

Regards,
Osama

#OCI #OracleCloud #Bastion #ZeroTrust #Terraform #CloudSecurity #IaC #OracleCloudInfrastructure #DevOps #PlatformEngineering #CloudArchitecture #TechBlog #Oracle #SSH #NetworkSecurity #PrivilegedAccess #CloudNative #IAM #SecureAccess #InfrastructureAsCode

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.