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
96 changes: 96 additions & 0 deletions modules/ai-guardrails/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# ai-guardrails

Org-policy guardrails for AI workloads — the prevention half of this repo's AI
story. The risks [CloudCanary](https://github.com/ChrisInvictus/CloudCanary)
*detects* become, where org policy can reach them, structurally impossible.
Companion to the [MCP trust-boundary threat model](../../docs/threat-models/mcp-trust-boundaries.md).

Every constraint this module applies is a **Google-managed (canned) org policy
constraint, verified present against a live organization** before the module was
written. No invented constraint names; the module's claims are checkable against
`GET /v2/{parent}/constraints`.

## The loop this closes

| Risk | Threat model | CloudCanary detects | This module prevents |
|---|---|---|---|
| Shadow AI — Vertex/Gemini enabled where unsanctioned | B2/B3, LLM03 | `ai-apis` adoption signal | `gcp.restrictServiceUsage` denies the AI surface at that scope |
| Internet-facing AI development surface | B3 (exposure) | *(compute/firewall signals, indirectly)* | `ainotebooks.restrictPublicIp` |
| Model data in unapproved regions | B3 (custody) | — | `gcp.resourceLocations` (opt-in, explicit regions) |
| AI data under Google-managed keys only | B3 (custody) | — | `gcp.restrictNonCmekServices` (opt-in) |

The adoption row is the pairing at its clearest: at scopes where AI is
sanctioned, CloudCanary's signal is a welcome heads-up; at scopes where this
module denies the services, the same event is an *error at enablement time* and
the alert should never fire. Guardrails stop what you've predicted; the canary
catches what you haven't.

## What this module deliberately does not do

- **Restrict who holds `roles/aiplatform.admin`.** Bounding role-holding is IAM
deny-policy territory, not org policy. Until an IAM deny policy is warranted,
CloudCanary's `ai-iam` signal is the compensating **detective** control — every
`aiplatform.*` grant is drift-alerted, so the allowlist question is answered by
review rather than silently unenforced. Named here so the gap is a decision,
not an omission.
- **See model inference.** Prompt injection and tool-output poisoning (threat
model **B5**) happen inside the host application's reasoning loop. No
infrastructure constraint observes them — which is the threat model's central
thesis about what is genuinely new here.

## Inputs

| Name | Type | Default | Purpose |
|---|---|---|---|
| `target_id` | string | — | Policy parent: `organizations/NNN`, `folders/NNN`, `projects/ID` |
| `enforce_restrict_ai_services` | bool | `false` | Deny AI services at this scope (turn on where AI is unsanctioned) |
| `denied_ai_services` | list | aiplatform, generativelanguage, notebooks | The denied surface |
| `enforce_no_public_notebooks` | bool | `true` | No external IPs on Workbench/notebooks |
| `allowed_locations` | list | `[]` | AI resource residency (empty = not applied) |
| `enforce_require_cmek` | bool | `false` | CMEK requirement for regulated estates |
| `cmek_required_services` | list | aiplatform, notebooks | Services under the CMEK rule |

## Outputs

`guardrails_applied` — the enforced configuration, for audit and evidence.

## Usage

```hcl
# Deny AI in the sandbox — experimentation happens on sanctioned paths only.
module "sandbox_ai_guardrails" {
source = "../ai-guardrails"
target_id = "folders/${var.sandbox_folder_id}"

enforce_restrict_ai_services = true # AI not sanctioned here
# no-public-notebooks defaults on; residency and CMEK stay off
}

# Govern AI in /workloads — allowed, but never on a public notebook,
# and only in US regions.
module "workloads_ai_guardrails" {
source = "../ai-guardrails"
target_id = "folders/${var.workloads_folder_id}"

allowed_locations = ["in:us-locations"]
}
```

Applied at a folder, a project vended by the
[project factory](../project-factory/) into it is born with these guardrails the
same way it's born with no default network — one more thing that's true before
the first API call.

## Design notes

- **Deny-list, not allowlist, for services.** `gcp.restrictServiceUsage` here
denies the AI surface specifically; this module governs AI adoption, not the
whole service catalog. An estate-wide service allowlist is a bigger decision
that belongs to the org baseline, not an AI module.
- **Secure-by-default where the default is free; opt-in where it costs.**
No-public-notebooks defaults on (no operational cost). Residency and CMEK are
explicit choices because they constrain real workflows and add key lifecycle.
- **Roadmap.** The `ainotebooks.*` family has seven more constraints (root
access, file downloads, terminal, VPC restrictions, upgrade schedules) —
candidates as the AI surface here grows. Custom constraints (CEL) for
Vertex endpoint IAM are the next step beyond canned coverage.
127 changes: 127 additions & 0 deletions modules/ai-guardrails/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# AI guardrails — the prevention half of this repo's AI story.
#
# CloudCanary's WATCH_AI_WORKLOADS *detects* AI-workload risk signals; this
# module makes the preventable ones impossible — the same prevent/detect pairing
# the whole project is built on, applied to the newest workload class. Companion
# to docs/threat-models/mcp-trust-boundaries.md.
#
# Every constraint below is a Google-managed (canned) org policy constraint,
# verified present against a live organization before this module was written —
# constraint names are ground truth, not plausible guesses:
#
# gcp.restrictServiceUsage deny AI services where they are not
# sanctioned — the guardrail form of
# CloudCanary's "AI API enabled" adoption
# signal. Shadow AI becomes unrepresentable
# at scopes where it is denied.
# → threat model B2/B3, OWASP LLM03.
#
# ainotebooks.restrictPublicIp no internet-facing Workbench/notebook
# instances — the AI-development surface's
# version of compute.vmExternalIpAccess.
# → threat model B3 (exposure).
#
# gcp.resourceLocations AI resources only in approved regions —
# where model artifacts and training data
# may live. → threat model B3 (custody).
#
# gcp.restrictNonCmekServices optional CMEK requirement for regulated
# estates; off by default at demo scale,
# same documented-and-deferred stance the
# factory takes on registry CMEK.
# → threat model B3 (custody).
#
# What org policy deliberately does NOT cover here — named, not omitted:
# - Restricting who may hold roles/aiplatform.admin is IAM-deny territory,
# not org policy. Until an IAM deny policy is warranted, CloudCanary's
# ai-iam signal is the compensating detective control (see README).
# - Model *inference* risks — prompt injection, tool-output poisoning
# (threat model B5) — live in the host application. No infrastructure
# constraint sees them; the threat model's thesis is precisely that this
# boundary is the new one.

terraform {
required_version = ">= 1.7"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 6.0"
}
}
}

# ---------------------------------------------------------- shadow-AI prevention
# Deny the AI service surface at scopes where AI work is not sanctioned.
# Applied to /sandbox (or any non-AI folder), enabling Vertex there stops being
# an alert and becomes an error. Deliberately a DENY list of AI services, not an
# allowlist of everything else — this module governs AI adoption, not the whole
# service catalog.
resource "google_org_policy_policy" "restrict_ai_services" {
count = var.enforce_restrict_ai_services ? 1 : 0

name = "${var.target_id}/policies/gcp.restrictServiceUsage"
parent = var.target_id

spec {
rules {
values {
denied_values = var.denied_ai_services
}
}
}
}

# ------------------------------------------------------- no public AI notebooks
# Workbench/notebook instances are the AI-development surface most likely to
# hold credentials, training data, and model access in one place. Boolean
# constraint: no external IPs on them, ever, at this scope.
resource "google_org_policy_policy" "no_public_ai_notebooks" {
count = var.enforce_no_public_notebooks ? 1 : 0

name = "${var.target_id}/policies/ainotebooks.restrictPublicIp"
parent = var.target_id

spec {
rules {
enforce = "TRUE"
}
}
}

# ---------------------------------------------------------------- data residency
# Where AI resources may physically live. Empty list = constraint not applied
# (count gate), so residency is opt-in with explicit regions — the operator
# states the policy rather than inheriting a guess.
resource "google_org_policy_policy" "ai_resource_locations" {
count = length(var.allowed_locations) > 0 ? 1 : 0

name = "${var.target_id}/policies/gcp.resourceLocations"
parent = var.target_id

spec {
rules {
values {
allowed_values = var.allowed_locations
}
}
}
}

# ------------------------------------------------------------------ CMEK (opt-in)
# Require customer-managed keys for the listed AI services. Off by default:
# CMEK adds a KMS keyring + rotation lifecycle that is pure operational surface
# until there is regulated data to protect.
resource "google_org_policy_policy" "require_ai_cmek" {
count = var.enforce_require_cmek ? 1 : 0

name = "${var.target_id}/policies/gcp.restrictNonCmekServices"
parent = var.target_id

spec {
rules {
values {
denied_values = var.cmek_required_services
}
}
}
}
10 changes: 10 additions & 0 deletions modules/ai-guardrails/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
output "guardrails_applied" {
description = "The enforced AI-guardrail configuration at this target, for audit and evidence"
value = {
target = var.target_id
restrict_ai_services = var.enforce_restrict_ai_services ? var.denied_ai_services : []
no_public_notebooks = var.enforce_no_public_notebooks
allowed_locations = var.allowed_locations
require_cmek = var.enforce_require_cmek ? var.cmek_required_services : []
}
}
64 changes: 64 additions & 0 deletions modules/ai-guardrails/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
variable "target_id" {
description = <<-EOT
Where these guardrails apply, as an org-policy parent — "organizations/NNN",
"folders/NNN", or "projects/ID". Applied at a folder (e.g. /sandbox to deny
AI there, or /workloads to govern it), the constraints inherit downward like
the rest of the org baseline; the project factory can pass a vended project
for per-project opt-in.
EOT
type = string
}

variable "enforce_restrict_ai_services" {
description = "Deny the AI service surface at this scope (shadow-AI prevention). Turn on where AI work is NOT sanctioned; leave off where it is."
type = bool
default = false
}

variable "denied_ai_services" {
description = "AI services denied when enforce_restrict_ai_services is on. gcp.restrictServiceUsage expects service names."
type = list(string)
default = [
"aiplatform.googleapis.com",
"generativelanguage.googleapis.com",
"notebooks.googleapis.com",
]
}

variable "enforce_no_public_notebooks" {
description = "Deny external IPs on Vertex Workbench / notebook instances (ainotebooks.restrictPublicIp). On by default — the AI-development surface's version of the org's no-public-VM stance."
type = bool
default = true
}

variable "allowed_locations" {
description = <<-EOT
Regions AI resources may live in (gcp.resourceLocations value groups or
region names, e.g. ["in:us-locations"] or ["us-east1", "us-central1"]).
Empty (the default) applies no residency constraint — residency is a policy
the operator states explicitly, not a default this module guesses.
EOT
type = list(string)
default = []
}

variable "enforce_require_cmek" {
description = <<-EOT
Require customer-managed encryption keys for the services below
(gcp.restrictNonCmekServices). Off by default: CMEK adds a KMS keyring and
rotation lifecycle that is pure operational surface at demo scale — the same
documented-and-deferred stance the factory takes on registry CMEK. Turn on
for regulated estates.
EOT
type = bool
default = false
}

variable "cmek_required_services" {
description = "Services that must use CMEK when enforce_require_cmek is on."
type = list(string)
default = [
"aiplatform.googleapis.com",
"notebooks.googleapis.com",
]
}
Loading