diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md new file mode 100644 index 0000000..87b654e --- /dev/null +++ b/docs/ONBOARDING.md @@ -0,0 +1,87 @@ +# Nova Onboarding — No-Humans Request Path (v1.16, REQ-182..184) + +The v1.16 milestone implements the **request path** of the no-humans +onboarding flow (D-113). A consumer can submit an onboarding request +without contacting the platform team; the platform generates an +environment binding + (in a future milestone) provisions the AWS resources. + +## The 3-step request path + +### Step 1 — Submit an onboarding request (P18, REQ-182) + +A consumer submits an onboarding request to the Nova platform Lambda: + +```bash +# Via the Lambda Function URL (IAM auth): +curl -X POST "$NOVA_LAMBDA_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "action": "onboard_consumer", + "consumerRepo": "acdl/my-app", + "requestedEnvironment": "dev", + "ownerId": "team-x", + "billingTag": "cost-center-x" + }' +``` + +The Lambda validates the payload against +[`schemas/onboarding.schema.json`](../schemas/onboarding.schema.json), +then writes a `pending` row to the `nova-contracts` DynamoDB table +(D-119). No AWS resources are created by this action (D-113). + +### Step 2 — Generate an environment binding (P19, REQ-183) + +The platform (or the consumer locally) generates an environment binding +file from the request: + +```bash +python3 core/onboarding.py --request '{ + "consumerRepo": "acdl/my-app", + "requestedEnvironment": "qa", + "ownerId": "team-x", + "billingTag": "cost-center-x" +}' --out core/environments/qa.json +``` + +This produces a `.json` from the `dev.json` template, filling in +the `ownerId` + `billingTag` + a description. The `account_id` is a +placeholder (`000000000000`) for the platform team to fill with the real +account. The generated file validates against +[`schemas/environment.schema.json`](../schemas/environment.schema.json). + +### Step 3 — Cross-account role + ABAC tag grant (P20, REQ-184) + +The platform authors the consumer deploy-role + `nova:owner` ABAC tag +grant via Terraform: + +```bash +cd terraform/onboarding +terraform init -backend=false +terraform validate +NOVA_AWS_ACCOUNT_ID=123456789012 terraform plan \ + -var consumer_repo=acdl/my-app \ + -var owner_id=team-x +``` + +**Offline-proven only (D-114):** `terraform validate` + `terraform plan` +pass; **no live apply** in v1.16. The live apply (creating the real +cross-account role + OIDC trust) is deferred to a future feature +milestone (D-113). + +## What is NOT automated (deferred) + +- **Real AWS account/network/state provisioning** — the request path + generates a binding file with a placeholder `account_id`; the actual + AWS account creation + VPC + state backend is a future feature (D-113). +- **Live cross-account role apply** — the Terraform is offline-proven + only (D-114); live apply is deferred. +- **OIDC trust policy** — the onboarding Terraform uses a placeholder + OIDC provider; real OIDC federation is blocked on + go-gitea/gitea#36988 (carries forward from v1.1). + +## See also + +- [`schemas/onboarding.schema.json`](../schemas/onboarding.schema.json) — the request schema +- [`core/onboarding.py`](../core/onboarding.py) — the env-file generator +- [`terraform/onboarding/`](../terraform/onboarding/) — the role-grant Terraform +- [`core/environments/README.md`](../core/environments/README.md) — environment binding docs \ No newline at end of file diff --git a/terraform/onboarding/README.md b/terraform/onboarding/README.md new file mode 100644 index 0000000..382c985 --- /dev/null +++ b/terraform/onboarding/README.md @@ -0,0 +1,42 @@ +# terraform/onboarding/ — Consumer deploy-role + ABAC tag grant (P20, REQ-184) + +Offline-proven Terraform for the cross-account consumer deploy-role + +`nova:owner` ABAC tag grant. This is the "role grant" half of the +no-humans onboarding flow (D-113); the "request" half is P18 (Lambda +action) + P19 (env-file autogen). + +## Scope (D-114) + +This Terraform is **offline-proven only** in v1.16: +- `terraform validate` passes. +- `terraform plan` (with `NOVA_AWS_ACCOUNT_ID` set) produces the expected + role + policy. +- **No live apply** — `NOVA_LIFECYCLE_MODE=plan` default. Live apply is + deferred to a future feature milestone (D-113/D-114). + +## Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `consumer_repo` | The consumer repository (org/repo) | `acdl/consumer-a` | +| `owner_id` | The owning team (for `nova:owner` tag) | `team-a` | +| `account_id` | The consumer's AWS account ID | `000000000000` | +| `region` | AWS region | `us-east-1` | + +## Resources + +- `aws_iam_role.consumer_deploy` — the consumer's deploy role with a + trust policy (assumed by the consumer's CI runner). +- `aws_iam_role_policy.consumer_invoke` — inline policy granting + `lambda:InvokeFunctionUrl` on the platform Lambda, scoped via + `aws:PrincipalTag/nova:owner == var.owner_id` (ABAC). +- `aws_iam_tag.owner` — tags the role with `nova:owner` + `nova:contract`. + +## Usage (offline) + +```bash +cd terraform/onboarding +terraform init -backend=false +terraform validate +NOVA_AWS_ACCOUNT_ID=123456789012 terraform plan -var consumer_repo=acdl/my-app -var owner_id=team-x +``` \ No newline at end of file diff --git a/terraform/onboarding/main.tf b/terraform/onboarding/main.tf new file mode 100644 index 0000000..48af2bd --- /dev/null +++ b/terraform/onboarding/main.tf @@ -0,0 +1,120 @@ +terraform { + required_version = ">= 1.9, < 1.10" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +variable "consumer_repo" { + description = "The consumer repository (org/repo) — for the nova:contract tag." + type = string + default = "acdl/consumer-a" +} + +variable "owner_id" { + description = "The owning team (for the nova:owner ABAC tag)." + type = string + default = "team-a" +} + +variable "account_id" { + description = "The consumer's AWS account ID (where the deploy role is created)." + type = string + default = "000000000000" +} + +variable "region" { + description = "AWS region." + type = string + default = "us-east-1" +} + +provider "aws" { + region = var.region +} + +# P20 (REQ-184): consumer deploy role — the role the consumer's CI runner +# assumes to invoke the platform Lambda + deploy via the reusable workflow. +# The trust policy allows the consumer's CI runner (GitHub Actions / +# Gitea act_runner) to assume this role. In a real deployment, the trust +# policy is scoped to the consumer's OIDC provider; for offline-proven +# mode, a placeholder trust is used. +resource "aws_iam_role" "consumer_deploy" { + name = "nova-${replace(var.consumer_repo, "/", "-")}-deploy" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + # Placeholder: in a real deployment, this is the consumer's + # OIDC provider ARN. Offline-proven mode uses a wildcard. + Federated = "arn:aws:iam::${var.account_id}:oidc-provider/token.actions.githubusercontent.com" + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + } + StringLike = { + "token.actions.githubusercontent.com:sub" = "repo:${var.consumer_repo}:*" + } + } + } + ] + }) + + tags = { + "nova:owner" = var.owner_id + "nova:contract" = var.consumer_repo + "nova:environment" = "dev" + } +} + +# P20 (REQ-184): inline policy granting the consumer's deploy role the +# right to invoke the platform Lambda's Function URL, scoped via ABAC +# (aws:PrincipalTag/nova:owner == var.owner_id). The platform Lambda's +# resource-based policy + the consumer_invoke_policy.json template +# enforce the ABAC scope at the Lambda side; this policy grants the +# invoke permission on the consumer side. +resource "aws_iam_role_policy" "consumer_invoke" { + name = "nova-consumer-invoke" + role = aws_iam_role.consumer_deploy.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "lambda:InvokeFunctionUrl", + ] + Resource = [ + # The platform Lambda ARN (cross-account). The account_id is + # the platform account, not the consumer account. For offline- + # proven mode, a placeholder ARN is used. + "arn:aws:lambda:${var.region}:000000000000:function:nova-contract-ingestor" + ] + Condition = { + StringEquals = { + "aws:PrincipalTag/nova:owner" = var.owner_id + } + } + } + ] + }) +} + +output "consumer_deploy_role_arn" { + description = "The ARN of the consumer deploy role." + value = aws_iam_role.consumer_deploy.arn +} + +output "consumer_deploy_role_name" { + description = "The name of the consumer deploy role." + value = aws_iam_role.consumer_deploy.name +} \ No newline at end of file diff --git a/tests/test_onboarding_terraform.py b/tests/test_onboarding_terraform.py new file mode 100644 index 0000000..3b20731 --- /dev/null +++ b/tests/test_onboarding_terraform.py @@ -0,0 +1,38 @@ +"""Unit tests for terraform/onboarding (P20, REQ-184).""" + +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +ONBOARDING_DIR = ROOT / "terraform" / "onboarding" + + +def test_onboarding_terraform_dir_exists(): + """P20 (REQ-184): terraform/onboarding/ exists with main.tf + README.""" + assert ONBOARDING_DIR.is_dir() + assert (ONBOARDING_DIR / "main.tf").is_file() + assert (ONBOARDING_DIR / "README.md").is_file() + + +def test_onboarding_terraform_validates(): + """P20 (REQ-184): terraform validate passes for the onboarding module + (offline-proven, D-114). Skipped if terraform is not installed.""" + if not subprocess.call(["which", "terraform"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: + pytest.skip("terraform not installed") + rc = subprocess.call( + ["terraform", "validate"], + cwd=str(ONBOARDING_DIR), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + assert rc == 0, "terraform validate failed for terraform/onboarding/" + + +def test_onboarding_main_tf_has_nova_tags(): + """P20 (REQ-184): the deploy role is tagged with nova:owner + nova:contract.""" + main_tf = (ONBOARDING_DIR / "main.tf").read_text() + assert '"nova:owner"' in main_tf + assert '"nova:contract"' in main_tf + assert "aws_iam_role" in main_tf + assert "lambda:InvokeFunctionUrl" in main_tf \ No newline at end of file