Skip to content
Open
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
145 changes: 145 additions & 0 deletions addons/gcp/okta-conditional-access/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# GCP Okta Conditional Access

Enables Fleet's [Okta conditional access](https://fleetdm.com/guides/okta-conditional-access-integration) on GCP using the Application Load Balancer's native mTLS support. When a device authenticates through Okta, the LB validates its certificate against the Fleet SCEP CA and forwards the serial number to Fleet via the `X-Client-Cert-Serial` header.

GCP's `ServerTLSPolicy` applies at the HTTPS proxy level, so this addon provisions a **dedicated second proxy and global IP** for the `okta.<fleet_domain>` subdomain — leaving the main Fleet UI proxy untouched and mTLS-free. No separate load balancer is needed (contrast with the AWS addon).

## Requirements

- Fleet deployment using `gcp/byo-project` (or equivalent with `GoogleCloudPlatform/lb-http/google//modules/serverless_negs`)
- A valid Fleet instance reachable to obtain the CA certificate
- The CA certificate in PEM format stored at `resources/conditional-ca.pem` in your Terraform directory

## Architecture

```text
fleet.example.com → 1.2.3.4 → fleet-lb-https-proxy (no mTLS) → Cloud Run
okta.fleet.example.com → 5.6.7.8 → fleet-okta-https-proxy (mTLS enforced) → Cloud Run
ServerTLSPolicy (REJECT_INVALID)
TrustConfig (Fleet SCEP CA)
```

Both proxies share the same URL map and backend service. The mTLS proxy adds the `X-Client-Cert-Serial` header before forwarding to the backend.

## Differences from AWS Addon

| Concern | AWS | GCP |
| --- | --- | --- |
| CA cert storage | S3 bucket | Inline in `TrustConfig` (no object storage needed) |
| mTLS termination | Separate ALB | Dedicated proxy on existing LB |
| Cert revocation | Supported | **Not supported** by GCP LB — see note below |
| Serial header | ALB-native header | Custom request header `{client_cert_serial_number}` |
| Extra infrastructure cost | Second ALB + global IP | Second global IP only |

> **Revocation note:** GCP Application Load Balancers do not perform certificate revocation checking. Revoked certs with otherwise-valid chains will pass mTLS validation at the LB. Fleet itself checks the serial against its device records, so devices with revoked certs will still be blocked by Fleet — but the LB will not drop the connection at the TLS handshake.

## Obtaining the CA Certificate

Run these commands from your Terraform directory:

```sh
mkdir -p resources
curl 'https://<your-fleet-domain>/api/fleet/conditional_access/scep?operation=GetCACert' --output cacert.tmp
openssl x509 -inform der -in cacert.tmp -out resources/conditional-ca.pem
rm cacert.tmp
```

## Usage

```hcl
module "okta_conditional_access" {
source = "github.com/fleetdm/fleet-terraform//addons/gcp/okta-conditional-access?depth=1&ref=tf-mod-addon-gcp-okta-conditional-access-v0.1.0"

project_id = var.project_id
ca_certificate_pem_file = "${path.module}/resources/conditional-ca.pem"
fleet_domain = "fleet.example.com"
}

module "fleet" {
source = "github.com/fleetdm/fleet-terraform//gcp/byo-project?depth=1&ref=..."

# ... your existing fleet config ...

# Wire in the mTLS policy, cert-serial header, and okta subdomain:
server_tls_policy = module.okta_conditional_access.server_tls_policy
backend_custom_request_headers = [module.okta_conditional_access.client_cert_header]
okta_subdomain = "okta.fleet.example.com"
}
```

Setting `okta_subdomain` on the `fleet` module causes `gcp/byo-project` to:

1. Provision a dedicated global IP (`fleet-okta-ip`)
2. Create a managed SSL cert for `okta.<fleet_domain>` only (`fleet-okta-cert`)
3. Create a second HTTPS proxy with the `ServerTLSPolicy` attached (`fleet-okta-https-proxy`)
4. Create a forwarding rule on the new IP → okta proxy
5. Add a URL map host rule redirecting `/api/fleet/conditional_access/idp/sso` to the okta subdomain
6. Create a DNS A record for `okta.<fleet_domain>` pointing to the new IP

## First-time Deployment Notes

When applying this addon to an existing Fleet deployment, Terraform must replace the main managed SSL certificate (it is recreated without the okta domain, which is now on its own cert). The existing certificate cannot be deleted while attached to the HTTPS proxy, causing a 409 conflict. Run these steps before `terraform apply`:

```sh
# 1. Create a temporary cert for the main domain
gcloud compute ssl-certificates create fleet-lb-cert-new \
--domains=<fleet-domain> \
--project=<project-id> \
--global

# 2. Swap the main proxy to the temp cert
gcloud compute target-https-proxies update fleet-lb-https-proxy \
--ssl-certificates=fleet-lb-cert-new \
--project=<project-id> \
--global

# 3. Remove the mTLS policy from the main proxy (if previously applied)
gcloud compute target-https-proxies update fleet-lb-https-proxy \
--project=<project-id> \
--global \
--clear-server-tls-policy

# 4. Delete the old cert (now detached)
gcloud compute ssl-certificates delete fleet-lb-cert \
--project=<project-id> --global --quiet

# 5. Remove stale resources from state
terraform state rm 'module.fleet.module.fleet_lb.google_compute_managed_ssl_certificate.default[0]'
terraform state rm 'module.fleet.module.fleet_lb.google_compute_url_map.default[0]' # if present

# 6. Apply
terraform apply

# 7. Clean up the temporary cert
gcloud compute ssl-certificates delete fleet-lb-cert-new \
--project=<project-id> --global --quiet
```

This is a one-time migration step. Future `terraform apply` runs will not require it.

## Provider Requirements

| Name | Version |
| --- | --- |
| terraform | ~> 1.11 |
| google | >= 6.35.0 |

## Inputs

| Name | Description | Type | Default | Required |
| --- | --- | --- | --- | --- |
| `project_id` | GCP project ID | `string` | — | yes |
| `customer_prefix` | Resource name prefix | `string` | `"fleet"` | no |
| `ca_certificate_pem_file` | Path to Fleet SCEP CA cert (PEM) | `string` | — | yes |
| `subdomain_prefix` | Subdomain prefix for the mTLS endpoint | `string` | `"okta"` | no |
| `fleet_domain` | Base Fleet domain e.g. `fleet.example.com` | `string` | — | yes |

## Outputs

| Name | Description |
| --- | --- |
| `server_tls_policy` | Self-link of the ServerTLSPolicy — pass to `server_tls_policy` on the fleet module |
| `client_cert_header` | Custom request header string — add to `backend_custom_request_headers` |
| `redirect_rules` | URL map path rules for the Okta SSO redirect |
| `trust_config_id` | The fully-qualified resource ID of the `google_certificate_manager_trust_config` |
31 changes: 31 additions & 0 deletions addons/gcp/okta-conditional-access/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
resource "google_certificate_manager_trust_config" "this" {
project = var.project_id
name = "${var.customer_prefix}-okta-trust-config"
description = "Fleet SCEP CA trust config for Okta conditional access mTLS"
location = "global"

trust_stores {
trust_anchors {
pem_certificate = file(var.ca_certificate_pem_file)
}
}
}

resource "google_network_security_server_tls_policy" "this" {
project = var.project_id
name = "${var.customer_prefix}-okta-mtls-policy"
description = "mTLS policy for Fleet Okta conditional access — rejects connections without a valid client cert"
location = "global"

mtls_policy {
client_validation_mode = "REJECT_INVALID"
client_validation_trust_config = google_certificate_manager_trust_config.this.id
}

lifecycle {
# GCP sometimes returns the project number instead of the project ID in this
# field, causing spurious replace plans. The trust config itself is immutable
# so ignoring drift here is safe.
ignore_changes = [mtls_policy[0].client_validation_trust_config]
}
}
27 changes: 27 additions & 0 deletions addons/gcp/okta-conditional-access/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
output "server_tls_policy" {
description = "Self-link of the ServerTLSPolicy. Pass to the fleet_lb module as server_tls_policy."
value = google_network_security_server_tls_policy.this.id
}

output "client_cert_header" {
description = "Custom request header string that forwards the client certificate serial number to Fleet. Add to backends.default.custom_request_headers in the fleet_lb module."
value = "X-Client-Cert-Serial: {client_cert_serial_number}"
}

output "redirect_rules" {
description = "Path matcher rules to add to the Fleet LB URL map, redirecting the Okta SSO path to the mTLS subdomain. Note: this uses GCP URL map path matcher shape, which differs from the AWS addon's ALB listener rule shape."
value = [{
paths = ["/api/fleet/conditional_access/idp/sso"]
url_redirect = {
https_redirect = true
host_redirect = "${var.subdomain_prefix}.${var.fleet_domain}"
path_redirect = "/api/fleet/conditional_access/idp/sso"
strip_query = false
}
}]
}

output "trust_config_id" {
description = "The fully-qualified resource ID of the google_certificate_manager_trust_config (projects/{project}/locations/global/trustConfigs/{name})."
value = google_certificate_manager_trust_config.this.id
}
26 changes: 26 additions & 0 deletions addons/gcp/okta-conditional-access/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
variable "project_id" {
description = "GCP project ID"
type = string
}

variable "customer_prefix" {
description = "Prefix used for resource names"
type = string
default = "fleet"
}

variable "ca_certificate_pem_file" {
description = "Path to the Fleet SCEP CA certificate in PEM format. Must be relative to the root module directory (where terraform apply is run) or an absolute path. Obtain with: curl 'https://<fleet-domain>/api/fleet/conditional_access/scep?operation=GetCACert' --output cacert.tmp && openssl x509 -inform der -in cacert.tmp -out ca.pem && rm cacert.tmp"
type = string
}

variable "subdomain_prefix" {
description = "Subdomain prefix for the mTLS endpoint (e.g. 'okta' produces okta.<fleet_domain>)"
type = string
default = "okta"
}

variable "fleet_domain" {
description = "The base Fleet domain, e.g. 'fleet.campusgroup.co'. Used to construct the mTLS redirect target."
type = string
}
9 changes: 9 additions & 0 deletions addons/gcp/okta-conditional-access/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
terraform {
required_version = "~> 1.11"
required_providers {
google = {
source = "hashicorp/google"
version = ">= 6.35.0"
}
}
}
8 changes: 7 additions & 1 deletion gcp/byo-project/cloud_run.tf
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ locals {
FLEET_MYSQL_DATABASE = var.database_config.database_name
FLEET_REDIS_ADDRESS = "${module.memstore.host}:${module.memstore.port}"
FLEET_REDIS_USE_TLS = "false"
#FLEET_UPGRADES_ALLOW_MISSING_MIGRATIONS = "1"
FLEET_UPGRADES_ALLOW_MISSING_MIGRATIONS = "1"
FLEET_LOGGING_JSON = "true"
FLEET_LOGGING_DEBUG = var.fleet_config.debug_logging
FLEET_SERVER_TLS = "false"
Expand Down Expand Up @@ -120,6 +120,7 @@ module "fleet-service" {

# --- Cloud Run Job (Migrations) ---
resource "google_cloud_run_v2_job" "fleet_migration_job" {
deletion_protection = false

name = "fleet-migration"
location = var.region
Expand Down Expand Up @@ -201,6 +202,11 @@ resource "terracurl_request" "exec" {
Authorization = "Bearer ${data.google_client_config.default.access_token}"
Content-Type = "application/json",
}

lifecycle {
# Bearer token rotates each apply, ignore to prevent spurious replacements
ignore_changes = [headers, destroy_headers]
}
}

resource "google_compute_region_network_endpoint_group" "neg" {
Expand Down
Loading
Loading