Paidwen

Workload identity federation gives each agent its identity without a copied key.

When your agents run on a platform that signs tokens for them, such as Kubernetes, AWS or GitHub Actions, you write one trust rule instead of copying one key per agent. The SDK exchanges the token of the platform for an agent token valid for 15 minutes, and Paidwen creates the agent at its first exchange. One rule covers 1 agent as well as 10,000.

A trust rule says which tokens become which agents.

You write trust rules in Settings, then Workload identity, or with POST /v1/trust-rules. A rule holds:

  • the issuer it trusts, compared exactly with the iss claim of the token;
  • the public keys of the issuer, which Paidwen reads at the address of the issuer, or which you paste when the issuer is not reachable from the internet;
  • a condition in CEL on the claims variable, the content of the token;
  • the name of the agent, in CEL on the same variable;
  • the environment, the team and the owner of the agents it creates.
JSON
{
  "name": "Production cluster",
  "issuer": "https://oidc.eks.us-west-2.amazonaws.com/id/6FA42E7BFDE8549CB3B1",
  "condition": "claims[\"kubernetes.io\"].namespace == \"agents\"",
  "agent_name": "claims[\"kubernetes.io\"].serviceaccount.name",
  "environment": "production",
  "team": "billing"
}

The condition and the name use the same CEL as your rules, with the string functions of Kubernetes, such as split and lowerAscii. A condition that reads a missing claim does not accept the token. When two rules accept the same token, the older rule gives the agent.

Your platform signs the token for your organization.

Each organization has an audience: https://api.paidwen.com/organizations/ followed by the identifier of the organization. Settings, then Workload identity, shows it with a copy button. You put this audience in the token your platform signs, so that a token signed for Paidwen serves only your organization.

The audience is not a secret. Only the signature of your platform proves the identity of the agent.

Kubernetes mounts the token in each pod.

Kubernetes projects a token of the service account into the pod, with the audience of your organization, and replaces it before it expires. The SDK reads the file at each exchange.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: billing
  namespace: agents
spec:
  selector:
    matchLabels:
      app: billing
  template:
    metadata:
      labels:
        app: billing
    spec:
      serviceAccountName: billing
      containers:
        - name: agent
          image: registry.example.com/billing-agent:1.4.0
          env:
            - name: PAIDWEN_IDENTITY_TOKEN_FILE
              value: /var/run/secrets/paidwen/token
          volumeMounts:
            - name: paidwen-token
              mountPath: /var/run/secrets/paidwen
              readOnly: true
      volumes:
        - name: paidwen-token
          projected:
            sources:
              - serviceAccountToken:
                  audience: https://api.paidwen.com/organizations/<organization id>
                  expirationSeconds: 3600
                  path: token
import { Paidwen } from "paidwen";

const paidwen = new Paidwen();
from paidwen import Paidwen

paidwen = Paidwen()

The client reads PAIDWEN_IDENTITY_TOKEN_FILE, as it reads PAIDWEN_API_KEY for a key. You can also pass the file with identityTokenFile in TypeScript, or identity_token_file in Python. Each service account becomes one agent, and the pods of a deployment share it, with one budget.

The issuer of an EKS cluster publishes its keys, so Paidwen reads them itself. When the issuer of your cluster is not reachable from the internet, you paste its keys, which kubectl get --raw /openid/v1/jwks prints, and you paste them again when the cluster changes them.

AWS signs a token for the role of the workload.

You turn on outbound identity federation in IAM, then Account settings, and you give the role of the workload the permission sts:GetWebIdentityToken. The issuer of your account looks like https://<id>.tokens.sts.global.api.aws, and the sub claim is the ARN of the role.

JSON
{
  "name": "AWS workers",
  "issuer": "https://4f7c1d2e-8a90-4b3c-9d6e-1f2a3b4c5d6e.tokens.sts.global.api.aws",
  "condition": "claims.sub.startsWith(\"arn:aws:iam::123456789012:role/agent-\")",
  "agent_name": "claims.sub.split(\"/\")[1]",
  "environment": "production"
}

The SDK contains no code for AWS: you give it a function that asks AWS for the token.

import { GetWebIdentityTokenCommand, STSClient } from "@aws-sdk/client-sts";
import { Paidwen } from "paidwen";

const sts = new STSClient({ region: "us-east-1" });

const paidwen = new Paidwen({
  identityToken: async () => {
    const answer = await sts.send(
      new GetWebIdentityTokenCommand({
        Audience: ["https://api.paidwen.com/organizations/<organization id>"],
        SigningAlgorithm: "RS256",
        DurationSeconds: 300,
      }),
    );
    return answer.WebIdentityToken ?? "";
  },
});
import boto3
from paidwen import Paidwen

sts = boto3.client("sts", region_name="us-east-1")


def aws_token() -> str:
    return sts.get_web_identity_token(
        Audience=["https://api.paidwen.com/organizations/<organization id>"],
        SigningAlgorithm="RS256",
        DurationSeconds=300,
    )["WebIdentityToken"]


paidwen = Paidwen(identity_token=aws_token)

GitHub Actions signs a token for the job.

The job needs the permission id-token: write. Its token carries the repository, the branch and the workflow, which your condition reads.

JSON
{
  "name": "Release workflow",
  "issuer": "https://token.actions.githubusercontent.com",
  "condition": "claims.repository == \"acme/billing-agent\" && claims.ref == \"refs/heads/main\"",
  "agent_name": "claims.repository",
  "environment": "test"
}
yaml
permissions:
  id-token: write
  contents: read
import { Paidwen } from "paidwen";

const paidwen = new Paidwen({
  identityToken: async () => {
    const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL ?? "");
    url.searchParams.set("audience", "https://api.paidwen.com/organizations/<organization id>");
    const response = await fetch(url, {
      headers: { authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}` },
    });
    const { value } = (await response.json()) as { value: string };
    return value;
  },
});
import os

import httpx
from paidwen import Paidwen


def github_token() -> str:
    response = httpx.get(
        os.environ["ACTIONS_ID_TOKEN_REQUEST_URL"],
        params={"audience": "https://api.paidwen.com/organizations/<organization id>"},
        headers={"authorization": f"Bearer {os.environ['ACTIONS_ID_TOKEN_REQUEST_TOKEN']}"},
    )
    response.raise_for_status()
    return response.json()["value"]


paidwen = Paidwen(identity_token=github_token)

GitHub Actions signs tokens for every repository on GitHub. Your condition must name your repository, or your organization, so that no other workflow becomes one of your agents.

The SDK exchanges the token, then renews it.

The SDK follows OAuth 2.0 Token Exchange: it sends the token of the platform to POST /v1/oauth/token at its first question, and receives an agent token valid for 15 minutes. It exchanges again when less than 5 minutes remain, and simultaneous questions share one exchange.

  • When the exchange gets no answer, or an error 5xx, the SDK applies onUnavailable, as for a decision.
  • When Paidwen refuses the token, the SDK throws RequestError with the reason, and the action never runs.
  • When the decision point refuses an agent token, the SDK exchanges again once and asks again.

Cutting a rule or an agent stops its tokens within a second.

Turning a trust rule off, removing it, disabling an agent or putting it in quarantine reaches the decision points in under a second, without waiting for the 15 minutes of the tokens. A disabled rule also refuses every new exchange.

Removing a trust rule keeps its agents, with their history. Another rule can adopt them when they hold no active key.

An agent declared by hand moves to a trust rule.

A trust rule adopts an agent of the same name and the same environment that holds no active key and belongs to no rule. You revoke the keys of the agent, and the rule adopts it at its first exchange, with its history, its permissions and its budget. A rule never takes an agent that holds a key or that belongs to another rule.

Workload identity has six limits.

  • A machine with no platform that signs for it, such as a laptop or a rented server, keeps a key.
  • A trust rule is as safe as its platform. A person who can create a service account in the namespace of your condition can create an agent.
  • The name of an agent comes from the token. Renaming a service account creates a new agent, without the budget or the permissions of the old one.
  • The exchange goes through the Paidwen API. When it does not answer, an agent keeps its token for up to 15 minutes, then the SDK applies onUnavailable.
  • Revoking the keys of an agent does not revoke its token. To cut an agent of a trust rule, you disable it, put it in quarantine or turn its rule off.
  • The journal does not say whether an agent proved its identity with a key or with a token.