Platform guides
04 · PATs for CI/CD

Give CI workspace access without sharing your cookie.

A Personal Access Token (PAT, prefix `pcft_live_*`) is the right shape for any non-human caller: deploy pipelines, infra-as-code, scheduled exporters, admin scripts.


1

When to mint a PAT

  • Deploy pipeline needs to provision an Auth app for the new environment + push templates.
  • Infra-as-code (Terraform, Pulumi) manages workspaces + roles declaratively.
  • Scheduled job exports the message log nightly for compliance / analytics.
  • Admin script a human runs from their laptop but doesn't want their cookie embedded in the script.

Don't mint a PAT for:

  • End-user identity. Customers' end- users go through Auth, not the Platform API.
  • Day-to-day operator workflows. Sign in to the console; that's what it's for.

2

Mint a PAT

PATs bind to one or more managed workspace policies(rows in workspace_policy). Author the policy first, then reference its id when minting. The two-step shape — instead of an inline policy on mint — means a teammate can rotate the PAT without re-asserting the permission set, and a single policy can be reused across many PATs and roles.

POST /v1/workspaces/:slug/policies — author the policy first
curl https://api.platform-auth.productcraft.co/v1/workspaces/acme/policies \
  -H 'authorization: Bearer <cookie-or-platform-jwt>' \
  -H 'content-type: application/json' \
  -d '{
    "name":        "ci-staging-deploy",
    "description": "Auth app provisioning + Mail template ops for CI",
    "policy": [
      {
        "effect":    "allow",
        "actions":   ["auth.create", "auth.read", "auth.update", "mail.create", "mail.update", "mail.read"],
        "resources": ["*"]
      }
    ]
  }'
# → { "id": "<policy-uuid>", "workspace_id": "...", ... }

curl https://api.platform-auth.productcraft.co/v1/workspaces/acme/api-keys \
  -H 'authorization: Bearer <cookie-or-platform-jwt>' \
  -H 'content-type: application/json' \
  -d '{
    "name":        "github-actions-staging-deploy",
    "description": "Deploys acme-staging app from CI",
    "policy_ids":  ["<policy-uuid-from-step-1>"]
  }'

Response — the only time the plaintext token is returned:

{
  "token": "pcft_live_thatappearsonce...",
  "record": {
    "id":              "<pak-uuid>",
    "workspace_id":    "<workspace-uuid>",
    "created_by":      "<account-uuid>",
    "name":            "github-actions-staging-deploy",
    "description":     "Deploys acme-staging app from CI",
    "token_prefix":    "pcft_live_thata",
    "policies": [
      { "id": "<policy-uuid>", "name": "ci-staging-deploy", "description": "..." }
    ],
    "last_used_at": null,
    "revoked_at":   null,
    "created_at":   "2026-05-25T...",
    "updated_at":   "2026-05-25T..."
  }
}

Platform-Auth stores only the hash. No recovery — if you lose the secret, rotate (Section 5) and update your secret store. Treat the token like a password.

Mint requires a human session. PAT mint/update/revoke is cookie/JWT-only on api.platform-auth.productcraft.co — a PAT cannot mint another PAT. Same for workspace create/delete and role/policy/invite mutations. Reads and PAT-introspect (the operations downstream services and pipelines need) do work with a PAT bearer.


3

Caller-narrowing on mint

You cannot grant a PAT permissions you don't yourself hold.

Same shape as role authoring (Chapter 2 — Section 5), and enforced wherever a policy is authored or bound: an admin (excluded from workspace.delete) can't author or bind a policy with workspace.delete for a PAT. Trying returns 403 Cannot grant actions you don't have: workspace.delete.

Implication for CI: if your pipeline needs broad permissions, mint the PAT from an owner account, not from a least-privilege admin. The PAT inherits the minter's authority cap.


4

Scope tightly

Patterns for common pipelines:

Read-only audit exporter
{
  "effect":    "allow",
  "actions":   ["workspace.audit.read", "mail.message.read"],
  "resources": ["*"]
}
Auth app operator (scoped to one existing app)
{
  "effect":    "allow",
  "actions":   ["auth.update", "auth.read"],
  "resources": ["pcft:heimdall:app/<app-uuid>"]
}
// App resources are scoped by app *id*, not slug. A provisioner that
// creates new apps needs "auth.create" on resources ["*"].
Mail template deploy (deny destructive)
[
  {
    "effect":    "allow",
    "actions":   ["mail.*"],
    "resources": ["*"]
  },
  {
    "effect":    "deny",
    "actions":   ["mail.delete"],
    "resources": ["*"]
  }
]

Explicit deny wins. The template-deploy PAT above can create, update, send, but not delete — even if a teammate later widens mail.* to include something destructive.


5

Rotate

Periodic rotation (90 days is a reasonable default) + on-suspicion rotation when a former teammate leaves or a secret might have leaked.

DELETE old, mint new
# Mint replacement first — re-uses the same managed policy from Section 2.
curl -X POST .../v1/workspaces/acme/api-keys \
  -H 'authorization: Bearer <admin-or-owner-cookie-or-jwt>' \
  -d '{ "name": "github-actions-staging-deploy-v2", "policy_ids": ["<policy-uuid>"] }' > new.json

# Update CI secret store with the new value

# After CI is using the new PAT, revoke the old
curl -X DELETE .../v1/workspaces/acme/api-keys/<old-id> \
  -H 'authorization: Bearer <admin-or-owner-cookie-or-jwt>'

Revocation is immediate at the source, but services that accept PAKs cache introspection results for up to 60 seconds — a revoked PAK can still authenticate against a cached entry for that window. Plan rotations assuming a one-minute tail.


6

Audit usage

Two signals. First, last_used_at on the PAT record (GET .../api-keys, also shown in the console) — PATs with no usage in > 30 days are candidates for cleanup. Second, the workspace audit log: PAT lifecycle events (platform.apikey.created, platform.apikey.revoked, …) and Platform API actions performed with the PAT are recorded with the key's id as the actor and the caller IP. Per-service activity (Mail sends, Auth app changes, …) lives in each service's own logs — see Chapter 5.

Filter the audit log for one PAK (client-side)
curl 'https://api.platform-auth.productcraft.co/v1/workspaces/acme/audit-logs?limit=100' \
  -H 'authorization: Bearer <token-or-pak>' \
  | jq --arg id "<pak-id>" '.data[] | select(.actor_id == $id or .resource_id == $id)'

7

Where to store the secret

  • GitHub Actions: repo or org-level secret, accessed via ${{ secrets.PRODUCTCRAFT_PAT }}.
  • GitLab CI: CI/CD variables, masked.
  • Vault / Doppler / AWS Secrets Manager: the right choice if you have multiple environments + CI + production services all needing tokens.
  • .env files committed to a repo: never. Even private repos. The audit trail of who-saw-what is hopeless once it lands in git.

8

Provisioning script — a complete example

.github/workflows/provision-staging.yaml
name: Provision staging
on:
  workflow_dispatch:
env:
  PAT: ${{ secrets.PRODUCTCRAFT_PAT }}
  WORKSPACE_ID: <workspace-uuid>
jobs:
  provision:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Create Auth app for staging
        run: |
          curl -fsS https://api.auth.productcraft.co/v1/apps \
            -H "authorization: Bearer $PAT" \
            -H 'content-type: application/json' \
            -d '{ "slug": "acme-staging", "display_name": "Acme (staging)",
                  "workspace_id": "'"$WORKSPACE_ID"'" }'

      - name: Sync role catalogue
        run: |
          curl -fsS https://api.auth.productcraft.co/v1/apps/<app-id>/roles \
            -H "authorization: Bearer $PAT" \
            -H 'content-type: application/json' \
            -d '{ "name": "viewer", "description": "Read-only" }' || true

      - name: Push Mail templates from repo
        # POST /templates is an idempotent upsert on (workspace, name)
        run: |
          for t in templates/*.json; do
            curl -fsS "https://api.mail.productcraft.co/v1/workspaces/$WORKSPACE_ID/templates" \
              -H "authorization: Bearer $PAT" \
              -H 'content-type: application/json' \
              -d @"$t"
          done

One PAT, narrowly scoped (auth.create + auth.read + mail.create + mail.update on the workspace), workflow-dispatch only. Template files are the create body: { "name": "...", "subject": "...", "body_html": "..." }.