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
26 changes: 26 additions & 0 deletions modules/pade/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,32 @@ It does not deploy a PADE broker, define broker-side authorization, or place pro

The module-managed bindings file is separate from PADE's default `~/.config/pade/bindings.yaml`, so user-managed bindings are not overwritten.

## Dynamic capabilities: trusted downstream bindings

`broker_capabilities` is optional. When supplied, its static capability names are rendered into broker-only bindings exactly as before. When omitted or set to `[]`, the module installs PADE with an **empty capability map**; demand alone never grants authority.

The module exposes three generic integration outputs:

- `bindings_path`: module-managed `~/.config/pade/coder-bindings.yaml` file (expand `~` to the workspace user's home in filesystem operations).
- `broker_binding`: non-secret, operator-configured `provider: broker` template with endpoint, audience, and runtime identity adapter. It is _not_ an authorization grant.
- `scripts`: Coder synchronization names. A dependent Coder script can use `coder exp sync want <self> ${join(" ", module.pade.scripts)}` to wait until PADE installation has finished.

A Coder template may omit the static capabilities:

```tf
module "pade" {
source = "git::https://github.com/After-Certainty/pade-coder.git//modules/pade"
agent_id = coder_agent.main.id
broker_endpoint = var.pade_broker_endpoint
}
```

A separately owned **trusted downstream integration** should verify or validate its input, apply operator-controlled support and mapping policy, and construct a complete PADE `version: "0.1"` YAML file with `capabilities` containing only approved, mapped capability names. Each resulting value can use `module.pade.broker_binding`. The downstream integration must atomically replace `module.pade.bindings_path` with file mode `0600` **after** the PADE installer completes and **before** dependent PADE workloads start. Do not merge against previous mappings or generate trusted bindings directly from unverified repository content. Missing validation, unsupported demand, failed generation, or failed writing must leave the workspace without usable bindings.

In dynamic mode, PADE's installer writes the empty map before any network download and again after installation, removing stale bindings even if a subsequent download fails. It intentionally does **not** install `rc-pade` or interpret Runtime Conditions; that belongs in an optional downstream Coder integration. The local file is workspace-user-writable, not a security isolation boundary. The broker still authenticates and authorizes each request; runtime isolation and downstream IAM are separate responsibilities.

Existing static templates remain compatible. This change does not alter PADE's protocol, broker policy, workload identity, or provider authority.

## Prerequisites

- A Linux workspace with `curl`, `tar`, `sha256sum`, `base64`, `awk`, and `find`.
Expand Down
82 changes: 80 additions & 2 deletions modules/pade/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ const BINDINGS_PATH = "/root/.config/pade/coder-bindings.yaml";
const requiredVars = {
agent_id: "foo",
broker_endpoint: "https://broker.example.com",
};

const staticVars = {
...requiredVars,
broker_capabilities: '["github.repo.read"]',
};

Expand Down Expand Up @@ -107,7 +111,7 @@ const setup = async (
const state = await runTerraformApply(
import.meta.dir,
{
...requiredVars,
...staticVars,
...vars,
},
customEnv,
Expand Down Expand Up @@ -187,7 +191,8 @@ describe("pade", () => {
expect(renderedInstaller(instance.script)).toBe(
template
.replace("${PADE_VERSION}", "v0.4.0")
.replace("${BINDINGS_B64}", Buffer.from(bindings).toString("base64")),
.replace("${BINDINGS_B64}", Buffer.from(bindings).toString("base64"))
.replace("${DYNAMIC_BINDINGS}", "0"),
);
const result = await run();
expect(result.exitCode).toBe(0);
Expand Down Expand Up @@ -320,6 +325,79 @@ describe("pade", () => {
});
});

it("supports trusted downstream bindings without a Terraform-time capability list", async () => {
// A caller can omit broker_capabilities entirely rather than supplying [].
const noStaticState = await runTerraformApply(
import.meta.dir,
requiredVars,
);
expect(noStaticState.outputs.broker_binding.value).toEqual({
provider: "broker",
broker: {
endpoint: "https://broker.example.com",
audience: "https://broker.example.com",
identity: "gce",
},
});

const { id, run, sh } = await setup({ broker_capabilities: "[]" });
expect((await run()).exitCode).toBe(0);
const initiallyEmpty = Bun.YAML.parse(
await readFileContainer(id, BINDINGS_PATH),
);
expect(initiallyEmpty).toEqual({ version: "0.1", capabilities: {} });
expect((await sh(`stat -c %a ${BINDINGS_PATH}`)).stdout.trim()).toBe("600");

// A trusted downstream script runs only after module.pade.scripts.
// It owns the entire replacement file; portable intent is not trusted.
const writer = await sh(`cat > ${BINDINGS_PATH} <<'EOF'
version: "0.1"
capabilities:
github.repo.read:
provider: broker
broker:
endpoint: https://broker.example.com
audience: https://broker.example.com
identity: gce
EOF
chmod 600 ${BINDINGS_PATH}`);
expect(writer.exitCode).toBe(0);
expect(Bun.YAML.parse(await readFileContainer(id, BINDINGS_PATH))).toEqual({
version: "0.1",
capabilities: {
"github.repo.read": noStaticState.outputs.broker_binding.value,
},
});

// On restart, the installer must clear stale downstream bindings,
// so a failed or missing downstream writer cannot retain old access.
expect((await run()).exitCode).toBe(0);
expect(Bun.YAML.parse(await readFileContainer(id, BINDINGS_PATH))).toEqual({
version: "0.1",
capabilities: {},
});

// Also revoke stale bindings when the next download fails before install.
expect(writer.exitCode).toBe(0);
await sh(`cat > ${BINDINGS_PATH} <<'EOF'
version: "0.1"
capabilities:
stale.capability:
provider: broker
broker:
endpoint: https://broker.example.com
audience: https://broker.example.com
identity: gce
EOF
chmod 600 ${BINDINGS_PATH}`);
await sh("sed -i '/linux-amd64/d' /fixtures/SHA256SUMS");
expect((await run()).exitCode).not.toBe(0);
expect(Bun.YAML.parse(await readFileContainer(id, BINDINGS_PATH))).toEqual({
version: "0.1",
capabilities: {},
});
});

it("configures shell startup files idempotently", async () => {
const { run, sh } = await setup();
await sh(
Expand Down
39 changes: 23 additions & 16 deletions modules/pade/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -60,40 +60,42 @@ variable "broker_identity" {

variable "broker_capabilities" {
type = set(string)
description = "Capability names that this workspace should resolve through the configured PADE broker."
description = "Static capability bindings managed by this module. Leave empty when a trusted downstream integration will populate the bindings after installation."
default = []
nullable = false

validation {
condition = (
length(var.broker_capabilities) > 0 &&
alltrue([for capability in var.broker_capabilities : trimspace(capability) != ""])
)
error_message = "broker_capabilities must contain at least one non-empty capability name."
condition = alltrue([for capability in var.broker_capabilities : trimspace(capability) != ""])
error_message = "broker_capabilities must contain only non-empty capability names."
}
}

locals {
pade_version = startswith(var.pade_version, "v") ? var.pade_version : "v${var.pade_version}"
broker_audience = var.broker_audience != null ? var.broker_audience : var.broker_endpoint

broker_binding = {
provider = "broker"
broker = {
endpoint = var.broker_endpoint
audience = local.broker_audience
identity = var.broker_identity
}
}

bindings = yamlencode({
version = "0.1"
capabilities = {
for capability in var.broker_capabilities : capability => {
provider = "broker"
broker = {
endpoint = var.broker_endpoint
audience = local.broker_audience
identity = var.broker_identity
}
}
for capability in var.broker_capabilities : capability => local.broker_binding
}
})
}

locals {
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
PADE_VERSION = local.pade_version
BINDINGS_B64 = base64encode(local.bindings)
PADE_VERSION = local.pade_version
BINDINGS_B64 = base64encode(local.bindings)
DYNAMIC_BINDINGS = length(var.broker_capabilities) == 0 ? "1" : "0"
})
}

Expand All @@ -107,6 +109,11 @@ module "coder_utils" {
install_script = local.install_script
}

output "broker_binding" {
description = "Non-secret broker binding template for trusted downstream integrations to use when constructing the bindings file. This is configuration, not an authorization grant."
value = local.broker_binding
}

output "bindings_path" {
description = "Workspace-local path written by this module. Set PADE_BINDINGS to this path or pass it with --bindings."
value = "~/.config/pade/coder-bindings.yaml"
Expand Down
32 changes: 31 additions & 1 deletion modules/pade/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,28 @@ run "defaults" {
error_message = "only the install synchronization name should be exposed"
}

assert {
condition = output.broker_binding == {
provider = "broker"
broker = {
endpoint = "https://broker.example.com"
audience = "https://broker.example.com"
identity = "gce"
}
}
error_message = "broker_binding should expose non-secret broker settings for a trusted downstream writer"
}

assert {
condition = strcontains(local.install_script, "PADE_VERSION='v0.4.0'")
error_message = "installer should be rendered with the normalized PADE version"
}

assert {
condition = strcontains(local.install_script, "DYNAMIC_BINDINGS='0'")
error_message = "static bindings should keep the existing install behavior"
}

assert {
condition = strcontains(local.install_script, "BINDINGS_B64='${base64encode(local.bindings)}'")
error_message = "installer should embed the generated bindings"
Expand Down Expand Up @@ -179,7 +196,20 @@ run "empty_capabilities" {
broker_capabilities = []
}

expect_failures = [var.broker_capabilities]
assert {
condition = yamldecode(local.bindings).capabilities == {}
error_message = "an empty capability set must produce a fail-closed empty bindings map"
}

assert {
condition = strcontains(local.install_script, "DYNAMIC_BINDINGS='1'")
error_message = "the installer must clear stale bindings before downloads in downstream-managed mode"
}

assert {
condition = strcontains(local.install_script, "BINDINGS_B64='${base64encode(local.bindings)}'")
error_message = "installer must reset bindings to an empty map until a trusted downstream writer runs"
}
}

run "blank_capability" {
Expand Down
12 changes: 12 additions & 0 deletions modules/pade/scripts/install.sh.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ set -euo pipefail

PADE_VERSION='${PADE_VERSION}'
BINDINGS_B64='${BINDINGS_B64}'
DYNAMIC_BINDINGS='${DYNAMIC_BINDINGS}'
INSTALL_DIR="$HOME/.local/bin"
BINDINGS_PATH="$HOME/.config/pade/coder-bindings.yaml"

Expand All @@ -15,6 +16,17 @@ for command in curl tar sha256sum base64 awk find; do
command -v "$command" > /dev/null 2>&1 || die "required command not found: $command"
done

# Before any network operation, revoke stale locally configured capabilities
# in downstream-managed mode. Missing downloads or installer failures must
# not leave capabilities from a previous successful workspace start in place.
if [ "$DYNAMIC_BINDINGS" = "1" ]; then
mkdir -p "$(dirname "$BINDINGS_PATH")"
bindings_tmp="$(mktemp "$BINDINGS_PATH.XXXXXX")"
printf '%s' "$BINDINGS_B64" | base64 -d > "$bindings_tmp"
chmod 0600 "$bindings_tmp"
mv "$bindings_tmp" "$BINDINGS_PATH"
fi

case "$(uname -s)" in
Linux) os="linux" ;;
*) die "Linux workspaces are required" ;;
Expand Down
Loading