diff --git a/modules/ai-guardrails/README.md b/modules/ai-guardrails/README.md new file mode 100644 index 0000000..4c0c9f7 --- /dev/null +++ b/modules/ai-guardrails/README.md @@ -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. diff --git a/modules/ai-guardrails/main.tf b/modules/ai-guardrails/main.tf new file mode 100644 index 0000000..218be87 --- /dev/null +++ b/modules/ai-guardrails/main.tf @@ -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 + } + } + } +} diff --git a/modules/ai-guardrails/outputs.tf b/modules/ai-guardrails/outputs.tf new file mode 100644 index 0000000..aa90071 --- /dev/null +++ b/modules/ai-guardrails/outputs.tf @@ -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 : [] + } +} diff --git a/modules/ai-guardrails/variables.tf b/modules/ai-guardrails/variables.tf new file mode 100644 index 0000000..062f3dd --- /dev/null +++ b/modules/ai-guardrails/variables.tf @@ -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", + ] +}