Platform guides
04 · Workspace API keys for CI/CD

Give CI workspace access without sharing your cookie.

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


1

When to mint a workspace API key

  • 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 workspace API key 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 workspace API key

Workspace API keys 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 workspace API key without re-asserting the permission set, and a single policy can be reused across many workspace API keys 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":              "<api-key-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. workspace API key mint/update/revoke is cookie/JWT-only on api.platform-auth.productcraft.co — a workspace API key cannot mint another workspace API key. Same for workspace create/delete and role/policy/invite mutations. Reads and API key introspection (the operations downstream services and pipelines need) do work with a workspace API key bearer.


3

Caller-narrowing on mint

You cannot grant a workspace API key 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 workspace API key. Trying returns 403 Cannot grant actions you don't have: workspace.delete.

Implication for CI: if your pipeline needs broad permissions, mint the workspace API key from an owner account, not from a least-privilege admin. The workspace API key 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 workspace API key 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 workspace API key, 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 workspace API keys cache introspection results for up to 60 seconds — a revoked workspace API key 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 workspace API key record (GET .../api-keys, also shown in the console) — workspace API keys with no usage in > 30 days are candidates for cleanup. Second, the workspace audit log: workspace API key lifecycle events (platform.apikey.created, platform.apikey.revoked, …) and Platform API actions performed with the workspace API key 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 workspace API key (client-side)
curl 'https://api.platform-auth.productcraft.co/v1/workspaces/acme/audit-logs?limit=100' \
  -H 'authorization: Bearer <token-or-api-key>' \
  | jq --arg id "<api-key-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_API_KEY }}.
  • 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:
  API_KEY: ${{ secrets.PRODUCTCRAFT_API_KEY }}
  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 $API_KEY" \
            -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 $API_KEY" \
            -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 $API_KEY" \
              -H 'content-type: application/json' \
              -d @"$t"
          done

One workspace API key, 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": "..." }.