From 80bddd2dea41242fa9f3d7ddd07f8b18961ae131 Mon Sep 17 00:00:00 2001 From: sophia chen Date: Thu, 21 May 2026 10:27:31 +1000 Subject: [PATCH 1/4] docs: design spec for Claude-issuable client API keys Captures the minimum backend change (one new Okta machine-auth scope mapped to Role.MAINTAINER) and the workflow for a /uid2-client-key skill that drives the runbook end-to-end. Scopes the work to UID2 client keys only; operator keys, CSTG, Databricks, and EUID are explicit follow-ups under the broader UID2-6903 epic. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...05-21-claude-client-key-issuance-design.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md diff --git a/docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md b/docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md new file mode 100644 index 00000000..2906e520 --- /dev/null +++ b/docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md @@ -0,0 +1,226 @@ +# Design: Claude-Issuable Client API Keys + +**Ticket:** [UID2-6903](https://thetradedesk.atlassian.net/browse/UID2-6903) (broader epic; this spec covers the *client API key* slice only) +**Runbook being automated:** [How to provision/create a new client API key/secret and private operator key](https://thetradedesk.atlassian.net/wiki/spaces/UID2/pages/25235533) +**Author:** Sophia Chen +**Date:** 2026-05-21 + +## Problem + +Issuing a client API key for a new partner is a recurring on-call task. The current manual flow (per the runbook) requires an engineer to: log in to UID2 Admin behind Tailscale, check that paperwork is signed, look up or create the site, click through Client Key Management to add the key with the right role, copy the one-time-revealed plaintext key + secret, paste them into a 1Password ephemeral share, reply in the Jira ticket and Slack thread, and update a tracker spreadsheet. The mechanical core — site-lookup-or-create plus key creation — is low-complexity but high-frequency, and it blocks partner onboarding behind on-call availability. + +UID2-6903 proposes exposing the relevant admin operations as documented, authenticated APIs so Claude can drive them. This spec narrows scope to the **client API key** path; operator keys and Databricks Cleanroom access are explicit non-goals (see [Out of scope](#out-of-scope)). + +## Goal + +A Claude skill (`/uid2-client-key`) that, given a Jira ticket key, drives the entire client-key-issuance workflow against the admin service and returns a 1Password ephemeral share link the engineer can paste into the ticket reply. The skill is invokable by an on-call engineer; the underlying auth model is machine-auth so the same plumbing can later support fully autonomous (cron) execution. + +## Non-goals + +- Replacing the engineer's judgment on whether paperwork is signed or which roles to grant. The skill surfaces the decision; a human still confirms. +- Removing the engineer from the loop on Slack reply, spreadsheet update, or marking the Jira ticket Done. The skill produces the artifacts; the engineer pastes/clicks. +- Operator-key creation, CSTG keypair creation, Databricks Cleanroom provisioning, EUID issuance, key rotation/disable. Each is a separate skill (see [Out of scope](#out-of-scope)). + +## Architecture + +Two deliverables, in two repos: + +``` +┌─────────────────────────┐ ┌──────────────────────────────┐ +│ uid2 plugin (skills) │ │ uid2-admin (Java/Vert.x) │ +│ │ │ │ +│ skills/ │ │ OktaCustomScope.java │ +│ uid2-client-key/ │ ──HTTP──▶ + CLIENT_KEY_ISSUANCE │ +│ SKILL.md │ Bearer │ → Role.MAINTAINER │ +│ │ token │ │ +└─────────────────────────┘ │ (existing endpoints, │ + │ │ no other code changes) │ + │ op CLI │ │ + ▼ │ POST /api/client/add │ +┌─────────────────────────┐ │ POST /api/site/add │ +│ 1Password ephemeral │ │ GET /api/site/list │ +│ share + Jira comment │ │ GET /api/client/list/... │ +└─────────────────────────┘ └──────────────────────────────┘ +``` + +### Backend change (uid2-admin) + +One new entry in `src/main/java/com/uid2/admin/auth/OktaCustomScope.java`: + +```java +CLIENT_KEY_ISSUANCE("uid2.admin.client-key-issuance", Role.MAINTAINER), +``` + +That's the entire code change. `AdminAuthMiddleware.validateAccessToken` (lines 140–162) already routes machine tokens through `isAuthorizedService(scopes)`, which looks up the role for each scope and admits the request if any required role matches. Adding the enum entry makes all `MAINTAINER`-protected endpoints callable by service tokens carrying this scope — including `/api/client/add`, `/api/site/add`, `/api/site/list`, and the read-only `/api/client/list*` endpoints. + +We deliberately do **not** grant `SUPER_USER` or `PRIVILEGED` via this scope. Delete (`/api/client/del`, requires `SUPER_USER`) and reveal-by-contact (`/api/client/reveal`, requires `PRIVILEGED`) remain unreachable from this scope — which is what we want, since the skill never needs them. + +### Okta service account (one-time setup, outside this repo) + +A new Okta service account (suggested name: `uid2-admin-claude-automation`) is created in the UID2 Okta tenant by an Okta admin, with the `uid2.admin.client-key-issuance` scope granted. Its client_id and client_secret are stored in 1Password under a well-known item name. Two such accounts are needed — one for the integ Okta tenant, one for prod — matching the two admin environments (`admin-integ.uidapi.com`, `admin-prod.uidapi.com`). + +This is a one-time operational task; the spec documents the requirement and the 1Password item name convention, but the setup itself is a manual provisioning step. + +### Skill (uid2 plugin) + +New skill at `skills/uid2-client-key/SKILL.md` (lives in the `ttd/uid2` plugin alongside the existing `auto-vul-scan`, `uid2-epic`, etc.). YAML frontmatter: + +```yaml +--- +name: uid2-client-key +description: > + Issue a UID2 client API key + secret for a partner from a Jira ticket. Reads + the ticket, ensures the site exists, calls the admin service to create the + key, packages the plaintext key+secret into a 1Password ephemeral share, and + comments the metadata back on the ticket. Usage: /uid2-client-key UID2-1234 + [--env integ|prod] +--- +``` + +Invocation: `/uid2-client-key UID2-1234` (defaults to prod per the runbook's "if not specified and paperwork is signed, assume prod" convention) or `/uid2-client-key UID2-1234 --env integ`. + +## Data flow + +``` +engineer types /uid2-client-key UID2-1234 + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 1. Preflight checks │ +│ - Tailscale reachable? (curl admin-{env}.uidapi.com) │ +│ - op CLI signed in? (op whoami) │ +│ - 1Password item exists for env's service account? │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 2. Read Jira ticket (Atlassian MCP) │ +│ Extract: participant name, type (publisher / advertiser / │ +│ DSP / data-provider), env if explicit, paperwork status, │ +│ contact email. │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 3. Decide & confirm │ +│ Present extracted plan to engineer: │ +│ - Participant: "Acme Co" (advertiser) │ +│ - Env: prod │ +│ - Role(s) to grant: MAPPER ← derived from type │ +│ - Paperwork: signed (per ticket field X) │ +│ Block on engineer confirmation. Halt if paperwork unsigned. │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 4. Acquire admin-service access token │ +│ a. op read 1password://...service-account...client-id │ +│ b. op read 1password://...service-account...client-secret │ +│ c. POST {okta_auth_server}/v1/token │ +│ grant_type=client_credentials │ +│ scope=uid2.admin.client-key-issuance │ +│ → bearer token, cached in-memory for the skill run only │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 5. Resolve site │ +│ GET /api/site/list │ +│ Match by exact name. If found → site_id. │ +│ If not found: │ +│ Confirm "no existing site named X — create new?" │ +│ POST /api/site/add?name=...&types=... │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 6. Check for existing key on this site │ +│ GET /api/client/list/{site_id} │ +│ If a key with the desired role already exists, surface it │ +│ and block — runbook says use a suffixed name in this case; │ +│ let the engineer decide rather than auto-suffix. │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 7. Create client key │ +│ POST /api/client/add?name=...&roles=...&site_id=... │ +│ Response = RevealedKey with plaintext key+secret │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 8. Package secrets │ +│ Write key + secret to a transient 1Password item, then │ +│ `op item share --expires-in 7d` to get the ephemeral link. │ +│ (No quotes in the share content — matches runbook step.) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 9. Comment back on the Jira ticket │ +│ Body = JSON metadata from RevealedKey response with │ +│ `key` and `secret` fields removed (matches runbook step │ +│ 20 exactly), plus the 1Password share URL. │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 10. Report summary to engineer with next-step checklist │ +│ (Slack reply, spreadsheet update, mark Done — manual) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Role mapping + +The runbook's role-by-type table, encoded into the skill (so the engineer doesn't have to remember it): + +| Participant type | Role granted | Notes | +|---|---|---| +| Publisher | `GENERATOR` | | +| Advertiser | `MAPPER` | | +| Data provider | `MAPPER` | Same endpoints as advertiser. | +| DSP | `ID_READER` | Skill prompts which client SDK; flagged in summary for follow-up. | +| Sharer | `SHARER` | Skill blocks and surfaces the runbook's prerequisite check — sharing onboarding must be complete first; engineer overrides if confirmed. | + +Multi-role keys are uncommon and explicitly cautioned against in the runbook ("a participant should never receive all API roles"). The skill grants exactly one role per invocation and instructs the engineer to re-run for a second role. + +## Error handling + +- **Tailscale not reachable.** Fail before touching credentials. Exit code 1, message says "Connect to UID2 Tailscale, then re-run." +- **Okta token request fails.** Surface the Okta error (401 = bad credentials, 400 = bad scope). Do not retry — credentials in 1Password are the most likely culprit and silent retry hides that. +- **Admin endpoint returns 401.** Almost certainly means the scope→role mapping isn't deployed yet. Surface explicitly and link to this design doc. +- **Site already exists with same name but different type.** Don't auto-update. Block and surface mismatch for engineer. +- **Client key already exists with the requested role on the site.** Block per runbook. Engineer either renames the new key or invokes a (future) rotate flow. +- **`/api/client/add` succeeds but 1Password share fails.** The plaintext key is not retrievable again. Skill writes the response to a local file at `~/uid2-client-key-recovery--.json` (chmod 600), tells the engineer where it is, and instructs them to share manually. Do not delete this file automatically — engineer cleans up after confirming the share. +- **Jira comment post fails.** Treat as warning, not failure. Key is already issued; print the comment body to terminal so the engineer can paste it manually. + +## Testing + +**Backend (uid2-admin):** +- Unit test for `OktaCustomScope.fromName("uid2.admin.client-key-issuance")` returns the new enum value with `Role.MAINTAINER`. There are existing tests in this style in the auth test package; mirror them. +- Integration test: with `is_auth_disabled=false` and a stubbed `AccessTokenVerifier` that returns a JWT carrying `scp: ["uid2.admin.client-key-issuance"]`, `POST /api/client/add` returns 200. There are existing analogous tests for `ss-portal`; mirror that pattern. +- No new tests needed for `ClientKeyService` itself — that code is unchanged. + +**Skill:** +- Manual run against integ: invoke `/uid2-client-key --env integ` end-to-end against a real `ttd_dev_demo` participant. Validates the full happy path including 1Password share creation and Jira comment posting. +- Dry-run flag (`--dry-run`): performs steps 1–6 (preflight, ticket read, site resolution, existing-key check) and prints the planned `/api/client/add` call without executing it. Used for the first prod run. +- No prod smoke test until a real ticket comes in; the dry-run flag is the proxy for that. + +## Out of scope + +The following are intentionally excluded from this spec. Each is a candidate follow-up. + +- **Operator key issuance.** Different protocols (aws-nitro / gcp-oidc / azure-cc), different endpoint, different post-creation steps (Private Operator List page). Separate skill, separate scope or reused. +- **CSTG client-side keypair.** Different endpoint family (`/api/client_side_keypairs/*`), CSTG has its own runbook. +- **Databricks Cleanroom provisioning.** Mentioned in UID2-6903 but unrelated to admin service. +- **EUID issuance.** Same code path on a different deployment; deferred until UID2 flow is validated. +- **Key rotation / disable.** Runbook covers these as separate sections. The skill should grow into them, but each adds judgment calls (when is the old key safe to disable?) that need their own thinking. +- **Slack thread reply.** A `slack-response` skill already exists in the plugin; engineer chains it manually. +- **Participant tracker spreadsheet update.** Manual for now. The runbook's spreadsheet has many fields not derivable from the admin response. +- **UID2 Portal automation.** The runbook's "Yes paperwork signed" branch routes to a portal team, not engineers — automation there is a separate org's problem. + +## Open questions + +- **1Password share automation.** `op item share` exists; need to verify it supports plaintext-content shares (not just file shares) and that the resulting URL is the same shape the runbook expects (the "ephemeral UID2 secrets" Confluence page should clarify). Implementation phase: confirm via `op item share --help` before committing to this path; fallback is to print the secret to terminal and instruct manual share. +- **Service-account credentials storage convention.** Suggested 1Password item path: `uid2-admin-claude-automation/{integ,prod}` with fields `okta_client_id`, `okta_client_secret`, `okta_auth_server`. Confirm with the Okta admin who provisions the accounts. From 211e914dcd09d5f1f2f07153f0899514199e87e8 Mon Sep 17 00:00:00 2001 From: sophia chen Date: Thu, 21 May 2026 10:33:11 +1000 Subject: [PATCH 2/4] docs: implementation plan for Claude-issuable client API keys Task-by-task plan spanning two repos: Phase 1 (this repo, uid2-admin): add CLIENT_KEY_ISSUANCE Okta scope mapped to Role.MAINTAINER, with parameterised tests mirroring the existing SS_PORTAL/SECRET_ROTATION patterns. Phase 2 (uid2-claude-skills): new /uid2-client-key skill driving the full runbook end-to-end. Phase 3 (operational): Okta service account provisioning + 1Password credential storage. Refs: UID2-6903 Spec: docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-21-claude-client-key-issuance.md | 801 ++++++++++++++++++ 1 file changed, 801 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-21-claude-client-key-issuance.md diff --git a/docs/superpowers/plans/2026-05-21-claude-client-key-issuance.md b/docs/superpowers/plans/2026-05-21-claude-client-key-issuance.md new file mode 100644 index 00000000..ed4da736 --- /dev/null +++ b/docs/superpowers/plans/2026-05-21-claude-client-key-issuance.md @@ -0,0 +1,801 @@ +# Claude Client-Key Issuance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable Claude to issue UID2 client API keys end-to-end via a `/uid2-client-key` skill, backed by a one-line scope addition to the admin service so the existing `MAINTAINER`-protected endpoints accept machine tokens. + +**Architecture:** Two thin deliverables, two repos. +1. `uid2-admin` (Java/Vert.x): add one entry to `OktaCustomScope` so `uid2.admin.client-key-issuance` → `Role.MAINTAINER`, plus parameterised tests mirroring the existing `SS_PORTAL`/`SECRET_ROTATION` pattern. No other backend code changes. +2. `uid2-claude-skills` (Markdown skill): new `skills/uid2-client-key/SKILL.md` that drives the runbook — parses a Jira ticket, ensures site exists, calls `POST /api/client/add`, packages the plaintext key+secret into a 1Password ephemeral share, and posts the metadata-only response back to the Jira ticket. + +**Tech Stack:** +- Backend: Java 17, Vert.x, JUnit 5 (parameterised), Mockito, Okta JWT verifier. +- Skill: Markdown frontmatter, Atlassian MCP, 1Password CLI (`op`), `curl`, shell. + +**Spec:** [`docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md`](../specs/2026-05-21-claude-client-key-issuance-design.md) (commit `80bddd2d`). + +--- + +## File structure + +### `uid2-admin` repo (this repo) + +| File | Change | Responsibility | +|---|---|---| +| `src/main/java/com/uid2/admin/auth/OktaCustomScope.java` | Modify (add one enum entry) | Map new machine scope to `Role.MAINTAINER`. | +| `src/test/java/com/uid2/admin/auth/OktaCustomScopeTest.java` | Modify (add one row to `testFromNameData`) | Cover `fromName` lookup for the new scope. | +| `src/test/java/com/uid2/admin/auth/AdminAuthMiddlewareTest.java` | Modify (extend two parameterised data providers) | Confirm authorised and unauthorised access for the new scope through the middleware. | + +### `uid2-claude-skills` repo (separate repo at `/Users/sophia.chen/ttdsrc/uid2-claude-skills`, origin `gitlab.adsrvr.org:uid2/uid2-claude-skills.git`) + +| File | Change | Responsibility | +|---|---|---| +| `skills/uid2-client-key/SKILL.md` | Create | Full end-to-end runbook executed by Claude. | + +### Operational artefact (not code) + +| Item | Owner | Responsibility | +|---|---|---| +| Okta service accounts `uid2-admin-claude-automation` (one per env) with scope `uid2.admin.client-key-issuance`; credentials stored in 1Password | Okta admin (manual) | One-time provisioning so the skill can obtain access tokens. | + +--- + +## Phase 1 — Backend scope addition (uid2-admin) + +The middleware in `src/main/java/com/uid2/admin/auth/AdminAuthMiddleware.java:140-162` already iterates through token scopes and admits the request if any maps to an allowed role. Adding one enum entry is therefore the entire functional change; the tests confirm both the lookup and the end-to-end auth decision. + +### Task 1: Failing test for `OktaCustomScope.fromName` on the new scope name + +**Files:** +- Modify: `src/test/java/com/uid2/admin/auth/OktaCustomScopeTest.java:12-19` + +- [ ] **Step 1: Add the new test row to `testFromNameData`** + +Open `src/test/java/com/uid2/admin/auth/OktaCustomScopeTest.java`. The existing `testFromNameData` method (lines 12-19) returns a `Stream` of `(scopeName, expectedEnum)` pairs. Add one row, between the existing `SITE_SYNC` row and the `dummy` row, so the method reads: + +```java +private static Stream testFromNameData() { + return Stream.of( + Arguments.of("uid2.admin.ss-portal", OktaCustomScope.SS_PORTAL), + Arguments.of("uid2.admin.secret-rotation", OktaCustomScope.SECRET_ROTATION), + Arguments.of("uid2.admin.site-sync", OktaCustomScope.SITE_SYNC), + Arguments.of("uid2.admin.client-key-issuance", OktaCustomScope.CLIENT_KEY_ISSUANCE), + Arguments.of("dummy", OktaCustomScope.INVALID) + ); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run from the repo root: + +```bash +mvn -pl . -am test -Dtest=OktaCustomScopeTest +``` + +Expected: compilation failure with `cannot find symbol: variable CLIENT_KEY_ISSUANCE` in `OktaCustomScope`. This is the "red" state. + +### Task 2: Add the enum entry to make the test compile and pass + +**Files:** +- Modify: `src/main/java/com/uid2/admin/auth/OktaCustomScope.java:10-15` + +- [ ] **Step 1: Insert the new enum entry** + +The current `OktaCustomScope` enum declaration is at lines 10-15. Add the new entry between `ENCLAVE_REGISTRAR` and `INVALID`: + +```java +@Getter +public enum OktaCustomScope { + SS_PORTAL("uid2.admin.ss-portal", Role.SHARING_PORTAL), + SECRET_ROTATION("uid2.admin.secret-rotation", Role.SECRET_ROTATION), + SITE_SYNC("uid2.admin.site-sync", Role.PRIVATE_OPERATOR_SYNC), + METRICS_EXPORT("uid2.admin.metrics-export", Role.METRICS_EXPORT), + ENCLAVE_REGISTRAR("uid2.admin.enclave-registrar", Role.ENCLAVE_REGISTRAR), + CLIENT_KEY_ISSUANCE("uid2.admin.client-key-issuance", Role.MAINTAINER), + INVALID("invalid", Role.UNKNOWN); + // ... rest unchanged +``` + +- [ ] **Step 2: Run the test to verify it passes** + +```bash +mvn -pl . -am test -Dtest=OktaCustomScopeTest +``` + +Expected: `BUILD SUCCESS`, all `testFromName` parameterised cases including the new one pass. + +### Task 3: Add the failing authorised-access test for the new scope through the middleware + +**Files:** +- Modify: `src/test/java/com/uid2/admin/auth/AdminAuthMiddlewareTest.java:278-284` + +- [ ] **Step 1: Extend `testAccessTokenGoodData`** + +The existing data provider at lines 278-284 lists `(scope, role)` tuples that should be admitted. Add one row: + +```java +private static Stream testAccessTokenGoodData() { + return Stream.of( + Arguments.of(OktaCustomScope.SS_PORTAL, OktaCustomScope.SS_PORTAL.getRole()), + Arguments.of(OktaCustomScope.SECRET_ROTATION, OktaCustomScope.SECRET_ROTATION.getRole()), + Arguments.of(OktaCustomScope.SITE_SYNC, OktaCustomScope.SITE_SYNC.getRole()), + Arguments.of(OktaCustomScope.CLIENT_KEY_ISSUANCE, OktaCustomScope.CLIENT_KEY_ISSUANCE.getRole()) + ); +} +``` + +- [ ] **Step 2: Run the parameterised test** + +```bash +mvn -pl . -am test -Dtest=AdminAuthMiddlewareTest#testAccessToken_GoodTokenAuthorized +``` + +Expected: PASS for all four rows. The new row exercises a token carrying scope `uid2.admin.client-key-issuance` being admitted on a route requiring `Role.MAINTAINER`. (It passes immediately because the middleware logic is unchanged — the test confirms behaviour through the middleware, not just the enum.) + +### Task 4: Add the failing unauthorised-access test for the new scope + +**Files:** +- Modify: `src/test/java/com/uid2/admin/auth/AdminAuthMiddlewareTest.java:252-261` + +- [ ] **Step 1: Extend `testAccessTokenUnauthorizedData`** + +Add two rows confirming `CLIENT_KEY_ISSUANCE` is rejected on routes requiring unrelated roles: + +```java +private static Stream testAccessTokenUnauthorizedData() { + return Stream.of( + Arguments.of(OktaCustomScope.SS_PORTAL.getName(), new Role[] {Role.PRIVATE_OPERATOR_SYNC}), + Arguments.of(OktaCustomScope.SS_PORTAL.getName(), new Role[] {Role.SECRET_ROTATION}), + Arguments.of(OktaCustomScope.SECRET_ROTATION.getName(), new Role[] {Role.SHARING_PORTAL}), + Arguments.of(OktaCustomScope.SECRET_ROTATION.getName(), new Role[] {Role.PRIVATE_OPERATOR_SYNC}), + Arguments.of(OktaCustomScope.SITE_SYNC.getName(), new Role[] {Role.SECRET_ROTATION}), + Arguments.of(OktaCustomScope.SITE_SYNC.getName(), new Role[] {Role.SHARING_PORTAL}), + Arguments.of(OktaCustomScope.CLIENT_KEY_ISSUANCE.getName(), new Role[] {Role.SUPER_USER}), + Arguments.of(OktaCustomScope.CLIENT_KEY_ISSUANCE.getName(), new Role[] {Role.PRIVILEGED}) + ); +} +``` + +These two rows assert that a `client-key-issuance` token is **rejected** on `SUPER_USER`-only routes (e.g. `/api/client/del`) and `PRIVILEGED`-only routes (e.g. `/api/client/reveal`), which matches the spec's threat model. + +- [ ] **Step 2: Run the parameterised test** + +```bash +mvn -pl . -am test -Dtest=AdminAuthMiddlewareTest#testAccessToken_GoodTokenUnauthorized +``` + +Expected: PASS for all rows including the two new ones (401 returned, inner handler not invoked). + +### Task 5: Run the whole auth-package test suite and full build + +- [ ] **Step 1: Run all auth tests** + +```bash +mvn -pl . -am test -Dtest='com.uid2.admin.auth.*' +``` + +Expected: PASS, no regressions. + +- [ ] **Step 2: Run the full build** + +```bash +mvn clean verify +``` + +Expected: `BUILD SUCCESS`. This catches anything the focused runs missed. + +### Task 6: Commit the backend change + +- [ ] **Step 1: Stage and commit** + +```bash +git add src/main/java/com/uid2/admin/auth/OktaCustomScope.java \ + src/test/java/com/uid2/admin/auth/OktaCustomScopeTest.java \ + src/test/java/com/uid2/admin/auth/AdminAuthMiddlewareTest.java +git commit -m "$(cat <<'EOF' +feat(auth): add client-key-issuance Okta scope for machine auth + +Adds a new OktaCustomScope mapped to Role.MAINTAINER so service-account +access tokens can call MAINTAINER-protected endpoints (POST /api/client/add, +POST /api/site/add, GET /api/site/list, GET /api/client/list/:siteId). + +This unblocks the /uid2-client-key Claude skill (see UID2-6903) without +exposing SUPER_USER or PRIVILEGED operations to the same scope. + +Tests mirror the existing parameterised SS_PORTAL/SECRET_ROTATION patterns +in OktaCustomScopeTest and AdminAuthMiddlewareTest. + +Refs: UID2-6903 +Design: docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md +EOF +)" +``` + +- [ ] **Step 2: Push and open PR** + +```bash +git push -u origin HEAD +gh pr create --title "feat(auth): add client-key-issuance Okta scope" --body "$(cat <<'EOF' +## Summary +- Add `uid2.admin.client-key-issuance` Okta custom scope mapped to `Role.MAINTAINER` +- Extend `OktaCustomScopeTest` and `AdminAuthMiddlewareTest` parameterised cases (authorised + unauthorised) +- Unblocks the `/uid2-client-key` Claude skill (UID2-6903) + +## Test plan +- [x] `mvn -pl . -am test -Dtest='com.uid2.admin.auth.*'` passes +- [x] `mvn clean verify` passes +- [ ] Reviewer confirms the scope→role mapping is appropriate (MAINTAINER only — no SUPER_USER / PRIVILEGED leakage) + +Design: `docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR URL printed. Reviewer needed before merge. + +--- + +## Phase 2 — Claude skill (uid2-claude-skills repo) + +The skill is a single `SKILL.md` that Claude reads as a runbook. There is no compile step, no unit test framework — verification is by running the skill end-to-end against the integ admin environment. Each task below adds one section to the file; the file is committed only after the full happy path is validated against integ. + +> Working directory for Phase 2 is `/Users/sophia.chen/ttdsrc/uid2-claude-skills` (separate repo, GitLab origin). Switch with `cd /Users/sophia.chen/ttdsrc/uid2-claude-skills` at the start of Task 7. Confirm with `git remote -v` — origin should be `git@gitlab.adsrvr.org:uid2/uid2-claude-skills.git`. + +### Task 7: Scaffold the skill directory and frontmatter + +**Files:** +- Create: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Create the directory** + +```bash +cd /Users/sophia.chen/ttdsrc/uid2-claude-skills +git checkout -b sc-UID2-6903-client-key-skill +mkdir -p skills/uid2-client-key +``` + +- [ ] **Step 2: Write the frontmatter and title** + +Create `skills/uid2-client-key/SKILL.md` with this initial content (subsequent tasks will append sections): + +```markdown +--- +name: uid2-client-key +description: > + Issue a UID2 client API key + secret for a partner from a Jira ticket. Reads + the ticket, ensures the site exists, calls the admin service to create the + key, packages the plaintext key+secret into a 1Password ephemeral share, and + comments the metadata back on the ticket. Usage: /uid2-client-key UID2-1234 + [--env integ|prod] +--- + +# UID2 Client Key Issuance + +> **Warning:** This skill performs production writes when `--env prod`. Always run with `--dry-run` first against a new participant pattern. + +Issue a UID2 client API key for a partner end-to-end. Replaces the manual +Confluence runbook: [How to provision/create a new client API key/secret](https://thetradedesk.atlassian.net/wiki/spaces/UID2/pages/25235533). + +Scope: **UID2 client API keys only**. Operator keys, CSTG keypairs, EUID keys, +and Databricks Cleanroom access are explicitly out of scope — separate skills. + +## Arguments + +| Position / flag | Required? | Default | Description | +|---|---|---|---| +| `$1` ticket key | yes | — | Jira ticket key, e.g. `UID2-1234`. | +| `--env integ\|prod` | no | `prod` | Admin service environment. | +| `--dry-run` | no | off | Perform steps 1-6 (everything up to `/api/client/add`) and print the planned call without executing. | +| `--name-suffix=` | no | empty | Append ` ` to the participant name when an existing key with the same role exists (per runbook: "Acme Corp" → "Acme Corp 2"). | +``` + +### Task 8: Add the Prerequisites section + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Prerequisites section** + +Append to `skills/uid2-client-key/SKILL.md`: + +```markdown +## Prerequisites + +- UID2 Tailscale connected. Confirm with: `tailscale status | head -1`. If not connected, halt with: "Connect to UID2 Tailscale (https://thetradedesk.atlassian.net/wiki/spaces/UID2/pages/520881958), then re-run." +- `op` CLI signed in (`op whoami` returns an email, not an error). +- Atlassian MCP available (the `mcp__claude_ai_Atlassian__*` tools). +- 1Password item present for the chosen env. Item naming convention: + - integ: `uid2-admin-claude-automation - integ` in the `UID2` vault + - prod: `uid2-admin-claude-automation - prod` in the `UID2` vault + Each item must carry fields: `okta_client_id`, `okta_client_secret`, `okta_auth_server`, `okta_audience`, `admin_base_url`. +``` + +### Task 9: Add Step 1 — preflight and Jira ticket read + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Step 1 section** + +Append to `skills/uid2-client-key/SKILL.md`: + +````markdown +## Step 1 — Preflight and read the Jira ticket + +### 1a. Preflight + +Run the preflight commands in order. Halt with a specific message on first failure. + +```bash +tailscale status >/dev/null 2>&1 || { echo "Tailscale not connected"; exit 1; } +op whoami >/dev/null 2>&1 || { echo "1Password CLI not signed in — run 'op signin'"; exit 1; } +``` + +### 1b. Resolve Atlassian cloudId + +```text +Call mcp__claude_ai_Atlassian__getAccessibleAtlassianResources. +Use the `id` for `thetradedesk.atlassian.net` as `cloudId` for all later calls. +``` + +### 1c. Read the ticket + +```text +Call mcp__claude_ai_Atlassian__getJiraIssue with the provided ticket key and +contentFormat="markdown". Extract from the response: + - summary → participant name (strip "API key request for " prefix if present) + - description → free-text. Scan for: participant type (publisher / advertiser / + DSP / data provider / sharer), environment hint (integ/prod), paperwork + status (signed / pending). + - reporter.emailAddress → contact email +``` + +If any of `participant_name`, `participant_type`, `contact_email` cannot be +inferred from the ticket, present what was extracted and ask the engineer to +fill in the missing fields. Do not guess. +```` + +### Task 10: Add Step 2 — confirm plan and check paperwork + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Step 2 section** + +```markdown +## Step 2 — Confirm the plan with the engineer + +Map participant type to the role that will be granted, per the runbook: + +| Participant type | Role granted | +|---|---| +| Publisher | `GENERATOR` | +| Advertiser | `MAPPER` | +| Data provider | `MAPPER` | +| DSP | `ID_READER` | +| Sharer | `SHARER` (also surface the runbook's sharing-onboarding prerequisite check; halt if not confirmed) | + +Present a confirmation block to the engineer like: + +``` +Plan: + Ticket: UID2-1234 + Participant: Acme Corp (advertiser) + Env: prod + Role: MAPPER + Paperwork: signed (per ticket description) + Contact: someone@acme.example + +Proceed? [y/N] +``` + +Halt on `N` or if paperwork is not confirmed signed. The runbook says: "if not +specified and paperwork has been signed by the client, assume Production." For +test/integ requests, the ticket must explicitly say so. +``` + +### Task 11: Add Step 3 — acquire Okta access token + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Step 3 section** + +````markdown +## Step 3 — Acquire admin-service access token + +Read the service-account credentials from 1Password (choose the item per `--env`): + +```bash +ITEM="uid2-admin-claude-automation - ${ENV}" +CLIENT_ID=$(op item get "$ITEM" --vault UID2 --fields label=okta_client_id --reveal) +CLIENT_SECRET=$(op item get "$ITEM" --vault UID2 --fields label=okta_client_secret --reveal) +AUTH_SERVER=$(op item get "$ITEM" --vault UID2 --fields label=okta_auth_server --reveal) +ADMIN_BASE_URL=$(op item get "$ITEM" --vault UID2 --fields label=admin_base_url --reveal) +``` + +Request the token (client_credentials grant): + +```bash +TOKEN=$(curl -fsS -X POST "${AUTH_SERVER}/v1/token" \ + -u "${CLIENT_ID}:${CLIENT_SECRET}" \ + -d "grant_type=client_credentials" \ + -d "scope=uid2.admin.client-key-issuance" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])") +``` + +On `curl` failure: print the HTTP error, do not retry. The most common causes +are: wrong credentials in 1Password (401), wrong scope name (400), wrong auth +server URL (404). Surface these explicitly — do not paper over with a retry. + +The token is held in shell variable scope for this skill invocation only. +Never write it to disk. +```` + +### Task 12: Add Step 4 — resolve or create the site + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Step 4 section** + +````markdown +## Step 4 — Resolve or create the site + +```bash +SITES_JSON=$(curl -fsS "${ADMIN_BASE_URL}/api/site/list" \ + -H "Authorization: Bearer ${TOKEN}") +``` + +Search the response for an exact `name` match against the extracted participant +name (case-insensitive trimmed comparison). Three branches: + +1. **Exact match.** Record `site_id` and move on. +2. **No match.** Confirm with the engineer: "No existing site named ''. + Create a new one? [y/N]". On `y`: + + ```bash + curl -fsS -X POST "${ADMIN_BASE_URL}/api/site/add?name=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$NAME")&types=${TYPES_CSV}" \ + -H "Authorization: Bearer ${TOKEN}" + ``` + + Record the `id` from the response as `site_id`. `${TYPES_CSV}` is the + uppercase comma-separated participant type list (e.g. `ADVERTISER`, + `PUBLISHER,ADVERTISER`). + +3. **Multiple similar matches** (case-insensitive substring). Print all matches + with their `id`s and halt. Engineer picks the right `site_id` and re-runs + with `--site-id=` (out of scope for the initial skill; tell the + engineer to use the Admin UI for this case and report which site_id to + re-run against). +```` + +### Task 13: Add Step 5 — check for existing key with the requested role + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Step 5 section** + +````markdown +## Step 5 — Check for an existing key with the requested role + +```bash +EXISTING_KEYS=$(curl -fsS "${ADMIN_BASE_URL}/api/client/list/${SITE_ID}" \ + -H "Authorization: Bearer ${TOKEN}") +``` + +If any element has the chosen role in its `roles` array **and** is not +disabled, halt with: + +``` +Site ${SITE_ID} already has a non-disabled key with role ${ROLE}: + key_id: + name: + created: + +The runbook says: name the new key with a numeric suffix (e.g. "Acme Corp" → +"Acme Corp 2"). Re-run with --name-suffix=2 to proceed, or use the Admin UI +to disable the old key first. +``` + +(The `--name-suffix` flag is part of the first version of this skill. Default +suffix is empty; the engineer adds it explicitly when needed.) +```` + +### Task 14: Add Step 6 — create the client key + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Step 6 section** + +````markdown +## Step 6 — Create the client key + +If `--dry-run`, print the planned `curl` command and stop here. + +```bash +KEY_NAME="${PARTICIPANT_NAME}${NAME_SUFFIX:+ ${NAME_SUFFIX}}" +RESPONSE=$(curl -fsS -X POST \ + "${ADMIN_BASE_URL}/api/client/add?name=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$KEY_NAME")&roles=${ROLE}&site_id=${SITE_ID}" \ + -H "Authorization: Bearer ${TOKEN}") +``` + +The response is a `RevealedKey` JSON object with this shape (Jackson +serialisation, confirmed against `RevealedKey.java`): + +```json +{ + "authorizable": { + "key_id": "UID2-C-P-12345-...", + "secret": "", + "name": "...", + "contact": "...", + "roles": ["MAPPER"], + "site_id": 999, + "service_id": 0, + "disabled": false, + "created": 1747800000 + }, + "plaintext_key": "UID2-C-P-12345-..." +} +``` + +This is the **only** copy of the plaintext key + secret that will ever be +available. From this point onward, treat the response as sensitive. Capture +two views: + +```bash +# Sensitive — for the 1Password share only +KEY=$(echo "$RESPONSE" | python3 -c "import json,sys;print(json.load(sys.stdin)['plaintext_key'])") +SECRET=$(echo "$RESPONSE" | python3 -c "import json,sys;print(json.load(sys.stdin)['authorizable']['secret'])") + +# Safe — for the Jira comment (plaintext_key removed top-level, secret removed from authorizable) +SAFE_JSON=$(echo "$RESPONSE" | python3 -c "import json,sys;d=json.load(sys.stdin);d.pop('plaintext_key',None);d.get('authorizable',{}).pop('secret',None);print(json.dumps(d,indent=2))") +``` + +If anything below this point fails before the key is shared, write the raw +response to `~/uid2-client-key-recovery-${TICKET}-$(date +%s).json` with +mode 0600 and tell the engineer the path. The plaintext key is **not** +retrievable from the admin service again. +```` + +### Task 15: Add Step 7 — package secrets into a 1Password ephemeral share + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Step 7 section** + +````markdown +## Step 7 — Share via 1Password ephemeral link + +Create a transient 1Password item containing the key and secret as plain text +fields (no quotes — the runbook specifies this), then share it with a 7-day +expiry: + +```bash +ITEM_TITLE="UID2 client key — ${PARTICIPANT_NAME} — ${TICKET}" +op item create \ + --category="Secure Note" \ + --title="$ITEM_TITLE" \ + --vault=UID2 \ + "client_key=${KEY}" \ + "client_secret=${SECRET}" \ + "site_id=${SITE_ID}" \ + "ticket=${TICKET}" +SHARE_URL=$(op item share "$ITEM_TITLE" --vault=UID2 --expires-in 7d --emails "${CONTACT_EMAIL}" 2>&1 | grep -Eo 'https://share\.1password\.[a-z]+/[^ ]+') +``` + +If `op item share` fails (it requires a paid 1Password plan with sharing +enabled and may not be available on every account), fall back to: + +```bash +echo "1Password share failed. Plaintext key + secret are in the 1Password +item titled '${ITEM_TITLE}' in the UID2 vault. Share it manually following +https://thetradedesk.atlassian.net/wiki/spaces/UID2/pages/403835076." +``` + +Clear `$KEY` and `$SECRET` from shell variables after this point: + +```bash +unset KEY SECRET +``` +```` + +### Task 16: Add Step 8 — post the Jira comment + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Step 8 section** + +````markdown +## Step 8 — Comment back on the Jira ticket + +The runbook requires posting the JSON metadata (with key/secret removed) plus +the 1Password share link as a comment. Example final comment body: + +``` +Issued via /uid2-client-key skill. + +Share link (expires in 7 days): ${SHARE_URL} + +Metadata: +${SAFE_JSON} +``` + +Post the comment: + +```text +Call mcp__claude_ai_Atlassian__addCommentToJiraIssue with: + cloudId = + issueIdOrKey = ${TICKET} + body = + responseContentFormat = "markdown" +``` + +If the comment post fails, print the full body to terminal so the engineer +can paste it manually. Do not retry automatically — Atlassian write +operations can succeed silently on the second attempt and produce duplicates. +```` + +### Task 17: Add Step 9 — final summary for the engineer + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Step 9 section** + +```markdown +## Step 9 — Summary and engineer next steps + +Print a final summary block: + +``` +✓ Client key issued for ${PARTICIPANT_NAME} (site_id=${SITE_ID}) + Role: ${ROLE} + key_id: + Ticket: ${TICKET} (comment posted) + Share link: ${SHARE_URL} + +Remaining manual steps (per runbook): + [ ] Reply in the Slack thread with the :approved-check: emoji + [ ] Update the UID2 Participant Information tracker spreadsheet + (https://ttdcorp-my.sharepoint.com/:x:/g/personal/luis_chelala_thetradedesk_com/EYkD4Z_1AZJCg_nj3gweFVwBKShBtyjl-jq-fHeY-l7-zQ) + [ ] Set ticket fields: Type=Task, Sprint=current UID2 sprint, + Story Points=0.01, Story Points Remaining=0.01, Assignee=you + [ ] Mark ticket Done +``` +``` + +### Task 18: Add the troubleshooting section + +**Files:** +- Modify: `skills/uid2-client-key/SKILL.md` + +- [ ] **Step 1: Append the Troubleshooting section** + +```markdown +## Troubleshooting + +| Symptom | Likely cause | Action | +|---|---|---| +| `401` from `${ADMIN_BASE_URL}/api/*` | The `client-key-issuance` scope→`MAINTAINER` mapping is not deployed yet in the target env. | Confirm release tag of uid2-admin in the env contains [the auth change](../specs/2026-05-21-claude-client-key-issuance-design.md). | +| `400 bad scope` from Okta | Service account does not have `uid2.admin.client-key-issuance` granted. | Ask Okta admin to grant the scope to the service-account application in the correct Okta tenant (integ vs prod). | +| `op item share` errors with "sharing not enabled" | 1Password plan does not support item sharing. | Use the manual Confluence-documented ephemeral-share flow. | +| Skill halts at "No existing site named X" but engineer knows the site exists | Name mismatch (whitespace, capitalisation). | Run `curl ${ADMIN_BASE_URL}/api/site/list -H "Authorization: Bearer ${TOKEN}" | jq '.[] | select(.name | test("X"; "i"))'`, then re-run with the exact name from the response. | +| Skill writes a recovery file | Something failed between key creation and share. | The recovery file holds the only copy of the plaintext key. Share it manually via 1Password, then delete the file. | +``` + +### Task 19: Integration test against the integ environment + +> **Blocker:** Phase 1 must be merged and deployed to the integ admin service before this task can succeed. The Okta service account must also be provisioned (see operational handoff below). + +**Files:** none (manual verification). + +- [ ] **Step 1: Set up a test Jira ticket** + +In the UID2 Jira project, create a Task titled "Test client key request — Acme Test" with a description that names a participant type (`advertiser`), explicitly says "for integ", and notes "paperwork signed (test)". Note the ticket key. + +- [ ] **Step 2: Dry run** + +```text +/uid2-client-key --env integ --dry-run +``` + +Expected: skill prints the resolved plan, the `POST /api/client/add` URL that *would* be called, and stops without writing anything. Confirm: +- Tailscale + 1Password preflight passes. +- Ticket fields parsed correctly. +- Site list call succeeds (returns JSON array). +- Existing-key check runs. +- Skill stops before `/api/client/add`. + +- [ ] **Step 3: Real run against integ** + +```text +/uid2-client-key --env integ +``` + +Expected: skill creates the key, creates the 1Password share, posts the comment. Manually verify in the integ Admin UI (`https://admin-integ.uidapi.com/`) that the client key exists with the right role and site. + +- [ ] **Step 4: Clean up** + +Disable the test client key via the integ Admin UI to avoid noise in future test runs. Delete the 1Password item. + +### Task 20: Commit and open MR for the skill + +- [ ] **Step 1: Commit** + +```bash +cd /Users/sophia.chen/ttdsrc/uid2-claude-skills +git add skills/uid2-client-key/SKILL.md +git commit -m "$(cat <<'EOF' +feat(uid2-client-key): add skill to issue UID2 client API keys + +End-to-end automation of the client-API-key issuance runbook: +- reads the Jira ticket +- resolves or creates the participant's site +- calls POST /api/client/add against the admin service +- packages the plaintext key+secret into a 1Password ephemeral share +- posts the metadata (key/secret removed) back to the ticket + +Requires uid2-admin to have the client-key-issuance Okta scope deployed +(IABTechLab/uid2-admin PR ) and a service-account credentials +1Password item per env. + +Refs: UID2-6903 +EOF +)" +``` + +- [ ] **Step 2: Push and open MR** + +```bash +git push -u origin HEAD +``` + +Open the MR via GitLab UI (or `glab mr create` if available). Title: `feat(uid2-client-key): add skill to issue UID2 client API keys`. Body should reference the spec, the integ test ticket key, and the uid2-admin PR. + +--- + +## Phase 3 — Operational handoff (no code) + +### Task 21: Document and request Okta service-account provisioning + +**Files:** none — this is a handoff to the Okta admin. + +- [ ] **Step 1: File a Jira ticket against the Okta admin team** + +Create a UID2 ticket (or whatever channel the Okta admin team uses) requesting: + +1. New Okta service-account application in the **integ** UID2 Okta tenant named `uid2-admin-claude-automation`, granted the custom scope `uid2.admin.client-key-issuance`. +2. Same in the **prod** UID2 Okta tenant. +3. For each: provide `client_id`, `client_secret`, the OAuth `/v1/token` URL, and the audience to the requester via a 1Password share. + +Block on this ticket before Phase 2 Task 19 (integ test). + +- [ ] **Step 2: Store credentials in 1Password** + +Once the Okta admin returns the credentials, create the two 1Password items in the UID2 vault using the names from Phase 2 Task 8 (`uid2-admin-claude-automation - integ` and `uid2-admin-claude-automation - prod`), with fields: + +| Field | Value | +|---|---| +| `okta_client_id` | as provided | +| `okta_client_secret` | as provided | +| `okta_auth_server` | as provided, e.g. `https://uid2.okta.com/oauth2/aus...` | +| `okta_audience` | as provided | +| `admin_base_url` | `https://admin-integ.uidapi.com` (integ) or `https://admin-prod.uidapi.com` (prod) | + +--- + +## Sequencing + +- Phase 1 (Tasks 1-6) can be developed standalone. Merge and ship to integ before starting Phase 2 Task 19. +- Phase 2 Tasks 7-18 (writing the skill content) can run in parallel with Phase 1 review. +- Phase 2 Task 19 is blocked on both Phase 1 deploy and Phase 3. +- Phase 3 is operational; kick it off as soon as Phase 1 PR opens so the credentials are ready when the skill is. From 29c2690b49f195f2a590a4a010c1e9ac3791d85b Mon Sep 17 00:00:00 2001 From: sophia chen Date: Thu, 21 May 2026 11:17:05 +1000 Subject: [PATCH 3/4] docs: revise client-key spec+plan per review Three changes from the user: - Drop the 1Password CLI dependency. Credentials live in shell env vars (UID2_ADMIN_CLAUDE__OKTA_CLIENT_ID/_SECRET, shared _AUTH_SERVER). Secret distribution becomes a print-to-terminal + engineer-driven out-of-band share via the existing Confluence-documented flow. - Site-not-found halts with a two-option prompt: re-check with a corrected name (loop), or authorise creating a new site. No more auto-create on a no-match miss. - Add 'test' env (https://admin.test.uidapi.com). Skill ships with three envs (test/integ/prod), and the integration test in Task 19 now runs against the test deployment, not integ. Same Okta tenant, one service account per env (per AdminAuthMiddleware:148 the JWT's environment claim must match the admin's configured environment). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-21-claude-client-key-issuance.md | 275 +++++++++++------- ...05-21-claude-client-key-issuance-design.md | 125 ++++---- 2 files changed, 248 insertions(+), 152 deletions(-) diff --git a/docs/superpowers/plans/2026-05-21-claude-client-key-issuance.md b/docs/superpowers/plans/2026-05-21-claude-client-key-issuance.md index ed4da736..9f3b2fcb 100644 --- a/docs/superpowers/plans/2026-05-21-claude-client-key-issuance.md +++ b/docs/superpowers/plans/2026-05-21-claude-client-key-issuance.md @@ -6,11 +6,11 @@ **Architecture:** Two thin deliverables, two repos. 1. `uid2-admin` (Java/Vert.x): add one entry to `OktaCustomScope` so `uid2.admin.client-key-issuance` → `Role.MAINTAINER`, plus parameterised tests mirroring the existing `SS_PORTAL`/`SECRET_ROTATION` pattern. No other backend code changes. -2. `uid2-claude-skills` (Markdown skill): new `skills/uid2-client-key/SKILL.md` that drives the runbook — parses a Jira ticket, ensures site exists, calls `POST /api/client/add`, packages the plaintext key+secret into a 1Password ephemeral share, and posts the metadata-only response back to the Jira ticket. +2. `uid2-claude-skills` (Markdown skill): new `skills/uid2-client-key/SKILL.md` that drives the runbook — parses a Jira ticket, ensures site exists (halt-and-confirm if missing), calls `POST /api/client/add`, prints the plaintext key+secret to the terminal for one-time copy, and posts the metadata-only response back to the Jira ticket. Three envs (`test` / `integ` / `prod`); credentials live in shell env vars, **not** in 1Password (no `op` CLI dependency). **Tech Stack:** - Backend: Java 17, Vert.x, JUnit 5 (parameterised), Mockito, Okta JWT verifier. -- Skill: Markdown frontmatter, Atlassian MCP, 1Password CLI (`op`), `curl`, shell. +- Skill: Markdown frontmatter, Atlassian MCP, `curl`, shell. No 1Password CLI. **Spec:** [`docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md`](../specs/2026-05-21-claude-client-key-issuance-design.md) (commit `80bddd2d`). @@ -36,7 +36,8 @@ | Item | Owner | Responsibility | |---|---|---| -| Okta service accounts `uid2-admin-claude-automation` (one per env) with scope `uid2.admin.client-key-issuance`; credentials stored in 1Password | Okta admin (manual) | One-time provisioning so the skill can obtain access tokens. | +| Okta service accounts `uid2-admin-claude-automation-{test,integ,prod}` with scope `uid2.admin.client-key-issuance` (same Okta tenant, but the per-env `environment` claim must match the target admin deployment's config) | Okta admin (manual) | One-time provisioning so the skill can obtain access tokens. | +| Shell env vars on the engineer's machine: `UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER`, `UID2_ADMIN_CLAUDE_{TEST,INTEG,PROD}_OKTA_CLIENT_ID`, `_OKTA_CLIENT_SECRET` | Engineer (one-time) | Provisioned credentials live here — not in 1Password. | --- @@ -267,9 +268,9 @@ name: uid2-client-key description: > Issue a UID2 client API key + secret for a partner from a Jira ticket. Reads the ticket, ensures the site exists, calls the admin service to create the - key, packages the plaintext key+secret into a 1Password ephemeral share, and - comments the metadata back on the ticket. Usage: /uid2-client-key UID2-1234 - [--env integ|prod] + key, prints the plaintext key + secret once to the terminal, and comments + metadata-only back on the ticket. Usage: /uid2-client-key UID2-1234 + [--env test|integ|prod] --- # UID2 Client Key Issuance @@ -287,8 +288,8 @@ and Databricks Cleanroom access are explicitly out of scope — separate skills. | Position / flag | Required? | Default | Description | |---|---|---|---| | `$1` ticket key | yes | — | Jira ticket key, e.g. `UID2-1234`. | -| `--env integ\|prod` | no | `prod` | Admin service environment. | -| `--dry-run` | no | off | Perform steps 1-6 (everything up to `/api/client/add`) and print the planned call without executing. | +| `--env test\|integ\|prod` | no | `prod` | Admin service environment. `test` → `https://admin.test.uidapi.com`; `integ` → `https://admin-integ.uidapi.com`; `prod` → `https://admin-prod.uidapi.com`. | +| `--dry-run` | no | off | Perform steps 1-7 (preflight, ticket read, plan confirm, token, site resolve, existing-key check) and print the planned `/api/client/add` call without executing. | | `--name-suffix=` | no | empty | Append ` ` to the participant name when an existing key with the same role exists (per runbook: "Acme Corp" → "Acme Corp 2"). | ``` @@ -305,12 +306,12 @@ Append to `skills/uid2-client-key/SKILL.md`: ## Prerequisites - UID2 Tailscale connected. Confirm with: `tailscale status | head -1`. If not connected, halt with: "Connect to UID2 Tailscale (https://thetradedesk.atlassian.net/wiki/spaces/UID2/pages/520881958), then re-run." -- `op` CLI signed in (`op whoami` returns an email, not an error). - Atlassian MCP available (the `mcp__claude_ai_Atlassian__*` tools). -- 1Password item present for the chosen env. Item naming convention: - - integ: `uid2-admin-claude-automation - integ` in the `UID2` vault - - prod: `uid2-admin-claude-automation - prod` in the `UID2` vault - Each item must carry fields: `okta_client_id`, `okta_client_secret`, `okta_auth_server`, `okta_audience`, `admin_base_url`. +- Required shell environment variables for the chosen `--env`: + - `UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER` (shared across envs; e.g. `https://uid2.okta.com/oauth2/aus...`) + - `UID2_ADMIN_CLAUDE__OKTA_CLIENT_ID` where `` is `TEST`, `INTEG`, or `PROD` + - `UID2_ADMIN_CLAUDE__OKTA_CLIENT_SECRET` +- Service accounts must already be provisioned in the UID2 Okta tenant with the `uid2.admin.client-key-issuance` scope granted, one per env (see Phase 3 / Task 21). ``` ### Task 9: Add Step 1 — preflight and Jira ticket read @@ -330,8 +331,19 @@ Append to `skills/uid2-client-key/SKILL.md`: Run the preflight commands in order. Halt with a specific message on first failure. ```bash +# Tailscale tailscale status >/dev/null 2>&1 || { echo "Tailscale not connected"; exit 1; } -op whoami >/dev/null 2>&1 || { echo "1Password CLI not signed in — run 'op signin'"; exit 1; } + +# Required env vars for the chosen --env +ENV_UC=$(echo "$ENV" | tr '[:lower:]' '[:upper:]') # test → TEST, etc. +CLIENT_ID_VAR="UID2_ADMIN_CLAUDE_${ENV_UC}_OKTA_CLIENT_ID" +CLIENT_SECRET_VAR="UID2_ADMIN_CLAUDE_${ENV_UC}_OKTA_CLIENT_SECRET" +for v in UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER "$CLIENT_ID_VAR" "$CLIENT_SECRET_VAR"; do + if [ -z "${!v}" ]; then + echo "Required env var not set: $v" + exit 1 + fi +done ``` ### 1b. Resolve Atlassian cloudId @@ -407,14 +419,19 @@ test/integ requests, the ticket must explicitly say so. ````markdown ## Step 3 — Acquire admin-service access token -Read the service-account credentials from 1Password (choose the item per `--env`): +Resolve credentials from env vars and the admin base URL from the `--env` flag: ```bash -ITEM="uid2-admin-claude-automation - ${ENV}" -CLIENT_ID=$(op item get "$ITEM" --vault UID2 --fields label=okta_client_id --reveal) -CLIENT_SECRET=$(op item get "$ITEM" --vault UID2 --fields label=okta_client_secret --reveal) -AUTH_SERVER=$(op item get "$ITEM" --vault UID2 --fields label=okta_auth_server --reveal) -ADMIN_BASE_URL=$(op item get "$ITEM" --vault UID2 --fields label=admin_base_url --reveal) +CLIENT_ID="${!CLIENT_ID_VAR}" +CLIENT_SECRET="${!CLIENT_SECRET_VAR}" +AUTH_SERVER="${UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER}" + +case "$ENV" in + test) ADMIN_BASE_URL="https://admin.test.uidapi.com" ;; + integ) ADMIN_BASE_URL="https://admin-integ.uidapi.com" ;; + prod) ADMIN_BASE_URL="https://admin-prod.uidapi.com" ;; + *) echo "Unknown --env: $ENV (expected test|integ|prod)"; exit 1 ;; +esac ``` Request the token (client_credentials grant): @@ -428,11 +445,11 @@ TOKEN=$(curl -fsS -X POST "${AUTH_SERVER}/v1/token" \ ``` On `curl` failure: print the HTTP error, do not retry. The most common causes -are: wrong credentials in 1Password (401), wrong scope name (400), wrong auth +are: wrong credentials in env vars (401), wrong scope name (400), wrong auth server URL (404). Surface these explicitly — do not paper over with a retry. The token is held in shell variable scope for this skill invocation only. -Never write it to disk. +Never write it to disk, and don't `echo` it. ```` ### Task 12: Add Step 4 — resolve or create the site @@ -443,34 +460,57 @@ Never write it to disk. - [ ] **Step 1: Append the Step 4 section** ````markdown -## Step 4 — Resolve or create the site +## Step 4 — Resolve the site (or authorise creating one) ```bash SITES_JSON=$(curl -fsS "${ADMIN_BASE_URL}/api/site/list" \ -H "Authorization: Bearer ${TOKEN}") ``` -Search the response for an exact `name` match against the extracted participant -name (case-insensitive trimmed comparison). Three branches: +Search the response for a case-insensitive trimmed `name` match against the +extracted participant name. Three branches: 1. **Exact match.** Record `site_id` and move on. -2. **No match.** Confirm with the engineer: "No existing site named ''. - Create a new one? [y/N]". On `y`: - ```bash - curl -fsS -X POST "${ADMIN_BASE_URL}/api/site/add?name=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$NAME")&types=${TYPES_CSV}" \ - -H "Authorization: Bearer ${TOKEN}" +2. **No match.** Print up to five similar names from `/api/site/list` (entries + whose `name` contains the participant-name tokens, case-insensitive), then + prompt the engineer with two options: + ``` + No site found named "". - Record the `id` from the response as `site_id`. `${TYPES_CSV}` is the - uppercase comma-separated participant type list (e.g. `ADVERTISER`, - `PUBLISHER,ADVERTISER`). + Closest existing sites: + - (id=) + - (id=) + ... -3. **Multiple similar matches** (case-insensitive substring). Print all matches - with their `id`s and halt. Engineer picks the right `site_id` and re-runs - with `--site-id=` (out of scope for the initial skill; tell the - engineer to use the Admin UI for this case and report which site_id to - re-run against). + Choose one: + (a) Re-check with a corrected name (type the exact name) + (b) Create a new site named "" + (q) Quit + + > + ``` + + - On **(a)**: read the engineer's typed name, set `$NAME` to it, and **loop + back to the `/api/site/list` lookup at the top of Step 4**. The skill + keeps looping until match, until engineer picks (b), or until engineer + quits. + - On **(b)**: confirm participant types from the ticket, then call: + + ```bash + curl -fsS -X POST "${ADMIN_BASE_URL}/api/site/add?name=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$NAME")&types=${TYPES_CSV}" \ + -H "Authorization: Bearer ${TOKEN}" + ``` + + Record the `id` from the response as `site_id`. `${TYPES_CSV}` is the + uppercase comma-separated participant-type list (e.g. `ADVERTISER`, + `PUBLISHER,ADVERTISER`). + - On **(q)**: exit 0 with a clean message. + +3. **Multiple exact matches** (rare; would indicate prior duplicates). Print + all matches with their `id`s and halt. Engineer resolves in the Admin UI + and re-runs with a more specific name. ```` ### Task 13: Add Step 5 — check for existing key with the requested role @@ -550,7 +590,7 @@ available. From this point onward, treat the response as sensitive. Capture two views: ```bash -# Sensitive — for the 1Password share only +# Sensitive — printed to terminal in Step 7, then unset KEY=$(echo "$RESPONSE" | python3 -c "import json,sys;print(json.load(sys.stdin)['plaintext_key'])") SECRET=$(echo "$RESPONSE" | python3 -c "import json,sys;print(json.load(sys.stdin)['authorizable']['secret'])") @@ -564,7 +604,7 @@ mode 0600 and tell the engineer the path. The plaintext key is **not** retrievable from the admin service again. ```` -### Task 15: Add Step 7 — package secrets into a 1Password ephemeral share +### Task 15: Add Step 7 — print plaintext key + secret to terminal (one-time) **Files:** - Modify: `skills/uid2-client-key/SKILL.md` @@ -572,39 +612,46 @@ retrievable from the admin service again. - [ ] **Step 1: Append the Step 7 section** ````markdown -## Step 7 — Share via 1Password ephemeral link +## Step 7 — Print plaintext key + secret to the terminal (one-time) -Create a transient 1Password item containing the key and secret as plain text -fields (no quotes — the runbook specifies this), then share it with a 7-day -expiry: +Print the plaintext key and secret to the terminal in a clearly-marked block. +This is the **only** time they appear in skill output; the admin service does +not allow retrieval again. ```bash -ITEM_TITLE="UID2 client key — ${PARTICIPANT_NAME} — ${TICKET}" -op item create \ - --category="Secure Note" \ - --title="$ITEM_TITLE" \ - --vault=UID2 \ - "client_key=${KEY}" \ - "client_secret=${SECRET}" \ - "site_id=${SITE_ID}" \ - "ticket=${TICKET}" -SHARE_URL=$(op item share "$ITEM_TITLE" --vault=UID2 --expires-in 7d --emails "${CONTACT_EMAIL}" 2>&1 | grep -Eo 'https://share\.1password\.[a-z]+/[^ ]+') +cat < Ticket: ${TICKET} (comment posted) - Share link: ${SHARE_URL} Remaining manual steps (per runbook): + [ ] Share the plaintext key + secret with the partner via the + Confluence-documented flow + (https://thetradedesk.atlassian.net/wiki/spaces/UID2/pages/403835076) [ ] Reply in the Slack thread with the :approved-check: emoji [ ] Update the UID2 Participant Information tracker spreadsheet (https://ttdcorp-my.sharepoint.com/:x:/g/personal/luis_chelala_thetradedesk_com/EYkD4Z_1AZJCg_nj3gweFVwBKShBtyjl-jq-fHeY-l7-zQ) @@ -685,47 +736,59 @@ Remaining manual steps (per runbook): | Symptom | Likely cause | Action | |---|---|---| -| `401` from `${ADMIN_BASE_URL}/api/*` | The `client-key-issuance` scope→`MAINTAINER` mapping is not deployed yet in the target env. | Confirm release tag of uid2-admin in the env contains [the auth change](../specs/2026-05-21-claude-client-key-issuance-design.md). | -| `400 bad scope` from Okta | Service account does not have `uid2.admin.client-key-issuance` granted. | Ask Okta admin to grant the scope to the service-account application in the correct Okta tenant (integ vs prod). | -| `op item share` errors with "sharing not enabled" | 1Password plan does not support item sharing. | Use the manual Confluence-documented ephemeral-share flow. | -| Skill halts at "No existing site named X" but engineer knows the site exists | Name mismatch (whitespace, capitalisation). | Run `curl ${ADMIN_BASE_URL}/api/site/list -H "Authorization: Bearer ${TOKEN}" | jq '.[] | select(.name | test("X"; "i"))'`, then re-run with the exact name from the response. | -| Skill writes a recovery file | Something failed between key creation and share. | The recovery file holds the only copy of the plaintext key. Share it manually via 1Password, then delete the file. | +| `401` from `${ADMIN_BASE_URL}/api/*` | Either the `client-key-issuance` scope→`MAINTAINER` mapping is not deployed in the target env, **or** the JWT's `environment` claim doesn't match (e.g. running `--env test` with the integ service account's credentials). | Confirm the uid2-admin release tag in the target env contains [the auth change](../specs/2026-05-21-claude-client-key-issuance-design.md). Confirm `UID2_ADMIN_CLAUDE__OKTA_CLIENT_ID` matches the chosen `--env`. | +| `400 bad scope` from Okta | The service account does not have `uid2.admin.client-key-issuance` granted. | Ask Okta admin to grant the scope to the per-env service-account application. | +| `Required env var not set` | Missing or unexported variable. | `echo $UID2_ADMIN_CLAUDE_TEST_OKTA_CLIENT_ID` (or the relevant var); if empty, source the engineer's credentials file / direnv. | +| Skill halts at "No site found" but engineer knows the site exists | Name mismatch (whitespace, capitalisation, EUID vs UID2 confusion). | Pick option **(a)** at the prompt and re-type the exact name from the Admin UI; the skill loops and re-checks. | +| Site list query returns the same name twice | Pre-existing duplicate sites; rare. | Resolve in the Admin UI; the skill cannot disambiguate. | ``` -### Task 19: Integration test against the integ environment +### Task 19: Integration test against the test environment -> **Blocker:** Phase 1 must be merged and deployed to the integ admin service before this task can succeed. The Okta service account must also be provisioned (see operational handoff below). +> **Blocker:** Phase 1 must be merged and deployed to the **test** admin service (`https://admin.test.uidapi.com`) before this task can succeed. The Okta service account for `--env test` must also be provisioned (see Task 21). **Files:** none (manual verification). - [ ] **Step 1: Set up a test Jira ticket** -In the UID2 Jira project, create a Task titled "Test client key request — Acme Test" with a description that names a participant type (`advertiser`), explicitly says "for integ", and notes "paperwork signed (test)". Note the ticket key. +In the UID2 Jira project, create a Task titled "Test client key request — Acme Test" with a description that names a participant type (`advertiser`), explicitly says "for test environment", and notes "paperwork signed (test)". Note the ticket key. -- [ ] **Step 2: Dry run** +- [ ] **Step 2: Confirm env vars are set** + +```bash +echo "${UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER:?missing}" >/dev/null +echo "${UID2_ADMIN_CLAUDE_TEST_OKTA_CLIENT_ID:?missing}" >/dev/null +echo "${UID2_ADMIN_CLAUDE_TEST_OKTA_CLIENT_SECRET:?missing}" >/dev/null +echo "All test-env credentials present." +``` + +Expected: prints `All test-env credentials present.` If any error, source the credentials before continuing. + +- [ ] **Step 3: Dry run** ```text -/uid2-client-key --env integ --dry-run +/uid2-client-key --env test --dry-run ``` Expected: skill prints the resolved plan, the `POST /api/client/add` URL that *would* be called, and stops without writing anything. Confirm: -- Tailscale + 1Password preflight passes. +- Tailscale + env-var preflight passes. - Ticket fields parsed correctly. -- Site list call succeeds (returns JSON array). +- Okta token acquired (no error printed). +- `GET /api/site/list` against `admin.test.uidapi.com` succeeds (returns JSON array). - Existing-key check runs. - Skill stops before `/api/client/add`. -- [ ] **Step 3: Real run against integ** +- [ ] **Step 4: Real run against test** ```text -/uid2-client-key --env integ +/uid2-client-key --env test ``` -Expected: skill creates the key, creates the 1Password share, posts the comment. Manually verify in the integ Admin UI (`https://admin-integ.uidapi.com/`) that the client key exists with the right role and site. +Expected: skill creates the key, prints plaintext key+secret to terminal, posts the metadata-only comment. Manually verify in the test Admin UI (`https://admin.test.uidapi.com/`) that the client key exists with the right role and site. -- [ ] **Step 4: Clean up** +- [ ] **Step 5: Clean up** -Disable the test client key via the integ Admin UI to avoid noise in future test runs. Delete the 1Password item. +Disable the test client key via the test Admin UI to avoid noise in future test runs. ### Task 20: Commit and open MR for the skill @@ -739,14 +802,16 @@ feat(uid2-client-key): add skill to issue UID2 client API keys End-to-end automation of the client-API-key issuance runbook: - reads the Jira ticket -- resolves or creates the participant's site +- resolves the participant's site (halt-and-confirm if missing; only + creates a new site with explicit engineer authorisation) - calls POST /api/client/add against the admin service -- packages the plaintext key+secret into a 1Password ephemeral share -- posts the metadata (key/secret removed) back to the ticket +- prints the plaintext key + secret once to the engineer's terminal + for out-of-band sharing with the partner +- posts the metadata (plaintext_key / secret removed) back to the ticket Requires uid2-admin to have the client-key-issuance Okta scope deployed -(IABTechLab/uid2-admin PR ) and a service-account credentials -1Password item per env. +(IABTechLab/uid2-admin PR ) and service-account credentials in shell +env vars on the engineer's machine, one set per env (test/integ/prod). Refs: UID2-6903 EOF @@ -771,31 +836,35 @@ Open the MR via GitLab UI (or `glab mr create` if available). Title: `feat(uid2- - [ ] **Step 1: File a Jira ticket against the Okta admin team** -Create a UID2 ticket (or whatever channel the Okta admin team uses) requesting: +Create a UID2 ticket (or whatever channel the Okta admin team uses) requesting **three** new Okta service-account applications in the UID2 Okta tenant, one per env. Each is granted the custom scope `uid2.admin.client-key-issuance` and issues tokens carrying the matching `environment` claim: + +1. `uid2-admin-claude-automation-test` → tokens with `environment=test` +2. `uid2-admin-claude-automation-integ` → tokens with `environment=integ` +3. `uid2-admin-claude-automation-prod` → tokens with `environment=prod` -1. New Okta service-account application in the **integ** UID2 Okta tenant named `uid2-admin-claude-automation`, granted the custom scope `uid2.admin.client-key-issuance`. -2. Same in the **prod** UID2 Okta tenant. -3. For each: provide `client_id`, `client_secret`, the OAuth `/v1/token` URL, and the audience to the requester via a 1Password share. +For each, request: `client_id`, `client_secret`, the OAuth `/v1/token` URL (shared across all three), and the audience. Hand these to the requester via the existing secure-share process (Confluence-documented ephemeral-secret flow). -Block on this ticket before Phase 2 Task 19 (integ test). +Block Phase 2 Task 19 on the `test` service account credentials being available. The integ/prod accounts can be provisioned later if needed. -- [ ] **Step 2: Store credentials in 1Password** +- [ ] **Step 2: Set credentials as shell env vars** -Once the Okta admin returns the credentials, create the two 1Password items in the UID2 vault using the names from Phase 2 Task 8 (`uid2-admin-claude-automation - integ` and `uid2-admin-claude-automation - prod`), with fields: +Once the Okta admin returns the credentials, set them as exported env vars (e.g. in `~/.zshrc`, `~/.bashrc`, a `direnv` `.envrc`, or whatever the engineer uses). Do **not** put them in 1Password and read them back via the CLI — the skill reads env vars directly. Convention: -| Field | Value | +| Variable | Value | |---|---| -| `okta_client_id` | as provided | -| `okta_client_secret` | as provided | -| `okta_auth_server` | as provided, e.g. `https://uid2.okta.com/oauth2/aus...` | -| `okta_audience` | as provided | -| `admin_base_url` | `https://admin-integ.uidapi.com` (integ) or `https://admin-prod.uidapi.com` (prod) | +| `UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER` | as provided by Okta admin, e.g. `https://uid2.okta.com/oauth2/aus...` | +| `UID2_ADMIN_CLAUDE_TEST_OKTA_CLIENT_ID` | client_id of the `-test` service account | +| `UID2_ADMIN_CLAUDE_TEST_OKTA_CLIENT_SECRET` | client_secret of the `-test` service account | +| `UID2_ADMIN_CLAUDE_INTEG_OKTA_CLIENT_ID` | client_id of the `-integ` service account | +| `UID2_ADMIN_CLAUDE_INTEG_OKTA_CLIENT_SECRET` | client_secret of the `-integ` service account | +| `UID2_ADMIN_CLAUDE_PROD_OKTA_CLIENT_ID` | client_id of the `-prod` service account | +| `UID2_ADMIN_CLAUDE_PROD_OKTA_CLIENT_SECRET` | client_secret of the `-prod` service account | --- ## Sequencing -- Phase 1 (Tasks 1-6) can be developed standalone. Merge and ship to integ before starting Phase 2 Task 19. +- Phase 1 (Tasks 1-6) can be developed standalone. Merge and ship to **test** (then integ, then prod) before Phase 2 Task 19 against each respective env. - Phase 2 Tasks 7-18 (writing the skill content) can run in parallel with Phase 1 review. -- Phase 2 Task 19 is blocked on both Phase 1 deploy and Phase 3. -- Phase 3 is operational; kick it off as soon as Phase 1 PR opens so the credentials are ready when the skill is. +- Phase 2 Task 19 is blocked on (a) Phase 1 deployed to **test**, and (b) Phase 3 Step 1+2 completed for the **test** env. +- Phase 3 is operational; kick it off as soon as the Phase 1 PR opens so the test-env credentials are ready when the skill is. Integ/prod service-account provisioning can follow once the test-env happy path is validated. diff --git a/docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md b/docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md index 2906e520..0442cd3c 100644 --- a/docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md +++ b/docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md @@ -13,12 +13,15 @@ UID2-6903 proposes exposing the relevant admin operations as documented, authent ## Goal -A Claude skill (`/uid2-client-key`) that, given a Jira ticket key, drives the entire client-key-issuance workflow against the admin service and returns a 1Password ephemeral share link the engineer can paste into the ticket reply. The skill is invokable by an on-call engineer; the underlying auth model is machine-auth so the same plumbing can later support fully autonomous (cron) execution. +A Claude skill (`/uid2-client-key`) that, given a Jira ticket key, drives the entire client-key-issuance workflow against the admin service. The skill prints the plaintext key+secret to the terminal once (the engineer then shares them via the Confluence-documented secret-sharing flow), and posts the metadata-only response back to the Jira ticket. The skill is invokable by an on-call engineer; the underlying auth model is machine-auth so the same plumbing can later support fully autonomous (cron) execution. ## Non-goals - Replacing the engineer's judgment on whether paperwork is signed or which roles to grant. The skill surfaces the decision; a human still confirms. - Removing the engineer from the loop on Slack reply, spreadsheet update, or marking the Jira ticket Done. The skill produces the artifacts; the engineer pastes/clicks. +- Automating the partner-facing secret share. The plaintext key+secret are printed to terminal once; the engineer shares them with the partner via the existing Confluence-documented ephemeral-secret flow (the 1Password web UI is fine — we just don't use the 1Password CLI from the skill). +- Auto-creating sites without engineer authorisation. If the site lookup misses, the skill halts and asks whether the participant name is correct (re-check) or whether to create a new site. +- 1Password CLI integration. Credentials live in shell environment variables; secret distribution is manual. - Operator-key creation, CSTG keypair creation, Databricks Cleanroom provisioning, EUID issuance, key rotation/disable. Each is a separate skill (see [Out of scope](#out-of-scope)). ## Architecture @@ -26,23 +29,37 @@ A Claude skill (`/uid2-client-key`) that, given a Jira ticket key, drives the en Two deliverables, in two repos: ``` -┌─────────────────────────┐ ┌──────────────────────────────┐ -│ uid2 plugin (skills) │ │ uid2-admin (Java/Vert.x) │ -│ │ │ │ -│ skills/ │ │ OktaCustomScope.java │ -│ uid2-client-key/ │ ──HTTP──▶ + CLIENT_KEY_ISSUANCE │ -│ SKILL.md │ Bearer │ → Role.MAINTAINER │ -│ │ token │ │ -└─────────────────────────┘ │ (existing endpoints, │ - │ │ no other code changes) │ - │ op CLI │ │ - ▼ │ POST /api/client/add │ -┌─────────────────────────┐ │ POST /api/site/add │ -│ 1Password ephemeral │ │ GET /api/site/list │ -│ share + Jira comment │ │ GET /api/client/list/... │ -└─────────────────────────┘ └──────────────────────────────┘ + shell env vars ┌──────────────────────────────┐ + UID2_ADMIN_CLAUDE_OKTA_* │ uid2-admin (Java/Vert.x) │ + per-env CLIENT_ID/SECRET │ │ + │ │ OktaCustomScope.java │ + ▼ │ + CLIENT_KEY_ISSUANCE │ +┌─────────────────────────┐ │ → Role.MAINTAINER │ +│ uid2 plugin (skills) │ ──HTTP──▶│ │ +│ skills/uid2-client-key/│ Bearer │ (existing endpoints, no │ +│ SKILL.md │ token │ other code changes) │ +└─────────────────────────┘ │ │ + │ │ POST /api/client/add │ + │ terminal output │ GET /api/site/list │ + ▼ │ GET /api/client/list/... │ +┌─────────────────────────┐ └──────────────────────────────┘ +│ Terminal: plaintext │ +│ key+secret (one-time); │ +│ Jira comment with │ +│ metadata only │ +└─────────────────────────┘ ``` +The three target admin deployments share one Okta tenant but each validates the JWT's `environment` claim against its own config (`AdminAuthMiddleware.java:148`), so a separate service account is provisioned per env: + +| `--env` | Admin base URL | Service-account env vars | +|---|---|---| +| `test` | `https://admin.test.uidapi.com` | `UID2_ADMIN_CLAUDE_TEST_OKTA_CLIENT_ID` / `_SECRET` | +| `integ` | `https://admin-integ.uidapi.com` | `UID2_ADMIN_CLAUDE_INTEG_OKTA_CLIENT_ID` / `_SECRET` | +| `prod` | `https://admin-prod.uidapi.com` | `UID2_ADMIN_CLAUDE_PROD_OKTA_CLIENT_ID` / `_SECRET` | + +Plus one shared variable: `UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER` (single Okta tenant URL, e.g. `https://uid2.okta.com/oauth2/aus...`). + ### Backend change (uid2-admin) One new entry in `src/main/java/com/uid2/admin/auth/OktaCustomScope.java`: @@ -57,9 +74,11 @@ We deliberately do **not** grant `SUPER_USER` or `PRIVILEGED` via this scope. De ### Okta service account (one-time setup, outside this repo) -A new Okta service account (suggested name: `uid2-admin-claude-automation`) is created in the UID2 Okta tenant by an Okta admin, with the `uid2.admin.client-key-issuance` scope granted. Its client_id and client_secret are stored in 1Password under a well-known item name. Two such accounts are needed — one for the integ Okta tenant, one for prod — matching the two admin environments (`admin-integ.uidapi.com`, `admin-prod.uidapi.com`). +The UID2 Okta tenant is the single source for service-account auth across all three admin deployments. Because the admin service validates the JWT's `environment` claim against its own configured `environment` value (`AdminAuthMiddleware.java:148`), one service account is provisioned **per env**: `uid2-admin-claude-automation-test`, `-integ`, `-prod`. Each is granted the `uid2.admin.client-key-issuance` scope, and each issues tokens carrying the matching `environment` claim. + +Credentials are handed to the engineer who installs the skill and stored as shell environment variables (e.g. via `direnv`, a gitignored `.envrc`, or the engineer's password manager of choice — but **not** the 1Password CLI; see [Non-goals](#non-goals)). The convention is documented in [Architecture](#architecture). -This is a one-time operational task; the spec documents the requirement and the 1Password item name convention, but the setup itself is a manual provisioning step. +This is a one-time operational task per env; the spec documents the requirement and the env-var convention, but the setup itself is a manual provisioning step. ### Skill (uid2 plugin) @@ -71,13 +90,13 @@ name: uid2-client-key description: > Issue a UID2 client API key + secret for a partner from a Jira ticket. Reads the ticket, ensures the site exists, calls the admin service to create the - key, packages the plaintext key+secret into a 1Password ephemeral share, and - comments the metadata back on the ticket. Usage: /uid2-client-key UID2-1234 - [--env integ|prod] + key, prints the plaintext key + secret once to the terminal, and comments + metadata-only back on the ticket. Usage: /uid2-client-key UID2-1234 + [--env test|integ|prod] --- ``` -Invocation: `/uid2-client-key UID2-1234` (defaults to prod per the runbook's "if not specified and paperwork is signed, assume prod" convention) or `/uid2-client-key UID2-1234 --env integ`. +Invocation: `/uid2-client-key UID2-1234` (defaults to prod per the runbook's "if not specified and paperwork is signed, assume prod" convention), `/uid2-client-key UID2-1234 --env integ`, or `/uid2-client-key UID2-1234 --env test`. ## Data flow @@ -87,9 +106,11 @@ engineer types /uid2-client-key UID2-1234 ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 1. Preflight checks │ -│ - Tailscale reachable? (curl admin-{env}.uidapi.com) │ -│ - op CLI signed in? (op whoami) │ -│ - 1Password item exists for env's service account? │ +│ - Tailscale reachable? (admin host responds) │ +│ - Required env vars set for chosen --env? │ +│ UID2_ADMIN_CLAUDE__OKTA_CLIENT_ID │ +│ UID2_ADMIN_CLAUDE__OKTA_CLIENT_SECRET │ +│ UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER │ └─────────────────────────────────────────────────────────────────┘ │ ▼ @@ -114,22 +135,25 @@ engineer types /uid2-client-key UID2-1234 ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 4. Acquire admin-service access token │ -│ a. op read 1password://...service-account...client-id │ -│ b. op read 1password://...service-account...client-secret │ -│ c. POST {okta_auth_server}/v1/token │ +│ a. Read CLIENT_ID/CLIENT_SECRET from env vars for chosen env │ +│ b. POST $UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER/v1/token │ │ grant_type=client_credentials │ │ scope=uid2.admin.client-key-issuance │ -│ → bearer token, cached in-memory for the skill run only │ +│ → bearer token, held in shell variable for this run only; │ +│ never written to disk │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 5. Resolve site │ │ GET /api/site/list │ -│ Match by exact name. If found → site_id. │ -│ If not found: │ -│ Confirm "no existing site named X — create new?" │ -│ POST /api/site/add?name=...&types=... │ +│ Match by case-insensitive trimmed name. If found → site_id. │ +│ If not found, prompt the engineer with two options: │ +│ (a) Confirm a corrected name (or paste exact name from │ +│ ticket); skill re-checks against /api/site/list │ +│ (b) Authorise creating a new site │ +│ → POST /api/site/add?name=...&types=... │ +│ Loop on (a) until match, halt, or engineer picks (b). │ └─────────────────────────────────────────────────────────────────┘ │ ▼ @@ -150,18 +174,20 @@ engineer types /uid2-client-key UID2-1234 │ ▼ ┌─────────────────────────────────────────────────────────────────┐ -│ 8. Package secrets │ -│ Write key + secret to a transient 1Password item, then │ -│ `op item share --expires-in 7d` to get the ephemeral link. │ -│ (No quotes in the share content — matches runbook step.) │ +│ 8. Print plaintext key + secret to terminal (one-time) │ +│ Surfaced in a clearly-marked block. Engineer copies them │ +│ and shares with the partner via the existing Confluence- │ +│ documented ephemeral-secret flow (1Password web share or │ +│ whatever the runbook currently prescribes). │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ 9. Comment back on the Jira ticket │ │ Body = JSON metadata from RevealedKey response with │ -│ `key` and `secret` fields removed (matches runbook step │ -│ 20 exactly), plus the 1Password share URL. │ +│ `plaintext_key` and `authorizable.secret` removed │ +│ (matches runbook step 20 exactly). No share URL — sharing │ +│ happens out-of-band. │ └─────────────────────────────────────────────────────────────────┘ │ ▼ @@ -188,12 +214,13 @@ Multi-role keys are uncommon and explicitly cautioned against in the runbook ("a ## Error handling - **Tailscale not reachable.** Fail before touching credentials. Exit code 1, message says "Connect to UID2 Tailscale, then re-run." -- **Okta token request fails.** Surface the Okta error (401 = bad credentials, 400 = bad scope). Do not retry — credentials in 1Password are the most likely culprit and silent retry hides that. -- **Admin endpoint returns 401.** Almost certainly means the scope→role mapping isn't deployed yet. Surface explicitly and link to this design doc. +- **Required env vars missing for the chosen `--env`.** List the missing variable names. Do not attempt to fall back to a different env. +- **Okta token request fails.** Surface the Okta error (401 = bad credentials, 400 = bad scope). Do not retry — wrong values in env vars are the most likely culprit and silent retry hides that. +- **Admin endpoint returns 401.** Almost certainly means the scope→role mapping isn't deployed yet in the target env, **or** the JWT's `environment` claim doesn't match the admin service (e.g. using the test service account against integ). Surface both possibilities and link to this design doc. - **Site already exists with same name but different type.** Don't auto-update. Block and surface mismatch for engineer. -- **Client key already exists with the requested role on the site.** Block per runbook. Engineer either renames the new key or invokes a (future) rotate flow. -- **`/api/client/add` succeeds but 1Password share fails.** The plaintext key is not retrievable again. Skill writes the response to a local file at `~/uid2-client-key-recovery--.json` (chmod 600), tells the engineer where it is, and instructs them to share manually. Do not delete this file automatically — engineer cleans up after confirming the share. -- **Jira comment post fails.** Treat as warning, not failure. Key is already issued; print the comment body to terminal so the engineer can paste it manually. +- **Client key already exists with the requested role on the site.** Block per runbook. Engineer either renames the new key (via `--name-suffix`) or invokes a (future) rotate flow. +- **`/api/client/add` succeeds but the Jira comment fails.** The plaintext key is not retrievable from the admin service again, but it was already printed to the terminal in step 8 — the engineer has it. Print the would-be comment body to the terminal so the engineer can paste it manually. Treat as warning, not failure. +- **`/api/client/add` returns a non-2xx status.** Print the response body and exit. Do not retry — admin write operations are not idempotent and a retry may create a duplicate key on the second attempt. ## Testing @@ -203,9 +230,9 @@ Multi-role keys are uncommon and explicitly cautioned against in the runbook ("a - No new tests needed for `ClientKeyService` itself — that code is unchanged. **Skill:** -- Manual run against integ: invoke `/uid2-client-key --env integ` end-to-end against a real `ttd_dev_demo` participant. Validates the full happy path including 1Password share creation and Jira comment posting. -- Dry-run flag (`--dry-run`): performs steps 1–6 (preflight, ticket read, site resolution, existing-key check) and prints the planned `/api/client/add` call without executing it. Used for the first prod run. -- No prod smoke test until a real ticket comes in; the dry-run flag is the proxy for that. +- Manual run against the **test** admin deployment (`https://admin.test.uidapi.com`): invoke `/uid2-client-key --env test` end-to-end against a real `ttd_dev_demo`-style participant. Validates the full happy path including the terminal-output secret print and Jira comment posting. The test env runs the same code path as integ/prod (real Okta auth, real scope check) so this is a faithful smoke test of the auth wiring. +- Dry-run flag (`--dry-run`): performs steps 1–7 (preflight, ticket read, plan confirm, token, site resolve, existing-key check) and prints the planned `/api/client/add` call without executing it. Used for the first integ/prod run on a new participant pattern. +- No integ or prod smoke test until a real ticket comes in; the test-env happy path plus the dry-run flag are the proxies for that. ## Out of scope @@ -222,5 +249,5 @@ The following are intentionally excluded from this spec. Each is a candidate fol ## Open questions -- **1Password share automation.** `op item share` exists; need to verify it supports plaintext-content shares (not just file shares) and that the resulting URL is the same shape the runbook expects (the "ephemeral UID2 secrets" Confluence page should clarify). Implementation phase: confirm via `op item share --help` before committing to this path; fallback is to print the secret to terminal and instruct manual share. -- **Service-account credentials storage convention.** Suggested 1Password item path: `uid2-admin-claude-automation/{integ,prod}` with fields `okta_client_id`, `okta_client_secret`, `okta_auth_server`. Confirm with the Okta admin who provisions the accounts. +- **Test env auth state.** This spec assumes `https://admin.test.uidapi.com` enforces Okta auth (same tenant as integ/prod, validates the JWT `environment=test` claim). Confirm with whoever owns the test deployment before the implementation phase — if test is in fact `is_auth_disabled=true`, the skill needs a no-token short-circuit for `--env test` and Phase 3 only provisions integ/prod service accounts. +- **Existing convention for storing service-account credentials locally.** This spec proposes shell env vars (`UID2_ADMIN_CLAUDE__OKTA_CLIENT_ID/_SECRET`, plus shared `UID2_ADMIN_CLAUDE_OKTA_AUTH_SERVER`). If the team already has a different convention for skill credentials (e.g. a shared `~/.config/uid2/` dir), follow that instead. From d0891bd2013825db39866a9183fc383d296248eb Mon Sep 17 00:00:00 2001 From: sophia chen Date: Thu, 21 May 2026 11:35:27 +1000 Subject: [PATCH 4/4] feat(auth): add client-key-issuance Okta scope for machine auth Adds a new OktaCustomScope mapped to Role.MAINTAINER so service-account access tokens can call MAINTAINER-protected endpoints (POST /api/client/add, POST /api/site/add, GET /api/site/list, GET /api/client/list/:siteId). This unblocks the /uid2-client-key Claude skill (see UID2-6903) without exposing SUPER_USER or PRIVILEGED operations to the same scope. Tests mirror the existing parameterised SS_PORTAL/SECRET_ROTATION patterns in OktaCustomScopeTest and AdminAuthMiddlewareTest. Refs: UID2-6903 Design: docs/superpowers/specs/2026-05-21-claude-client-key-issuance-design.md Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/java/com/uid2/admin/auth/OktaCustomScope.java | 1 + .../java/com/uid2/admin/auth/AdminAuthMiddlewareTest.java | 7 +++++-- src/test/java/com/uid2/admin/auth/OktaCustomScopeTest.java | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/uid2/admin/auth/OktaCustomScope.java b/src/main/java/com/uid2/admin/auth/OktaCustomScope.java index c60a0814..f35b0950 100644 --- a/src/main/java/com/uid2/admin/auth/OktaCustomScope.java +++ b/src/main/java/com/uid2/admin/auth/OktaCustomScope.java @@ -12,6 +12,7 @@ public enum OktaCustomScope { SITE_SYNC("uid2.admin.site-sync", Role.PRIVATE_OPERATOR_SYNC), METRICS_EXPORT("uid2.admin.metrics-export", Role.METRICS_EXPORT), ENCLAVE_REGISTRAR("uid2.admin.enclave-registrar", Role.ENCLAVE_REGISTRAR), + CLIENT_KEY_ISSUANCE("uid2.admin.client-key-issuance", Role.MAINTAINER), INVALID("invalid", Role.UNKNOWN); private final String name; private final Role role; diff --git a/src/test/java/com/uid2/admin/auth/AdminAuthMiddlewareTest.java b/src/test/java/com/uid2/admin/auth/AdminAuthMiddlewareTest.java index 8c9cc49f..35c01341 100644 --- a/src/test/java/com/uid2/admin/auth/AdminAuthMiddlewareTest.java +++ b/src/test/java/com/uid2/admin/auth/AdminAuthMiddlewareTest.java @@ -256,7 +256,9 @@ private static Stream testAccessTokenUnauthorizedData() { Arguments.of(OktaCustomScope.SECRET_ROTATION.getName(), new Role[] {Role.SHARING_PORTAL}), Arguments.of(OktaCustomScope.SECRET_ROTATION.getName(), new Role[] {Role.PRIVATE_OPERATOR_SYNC}), Arguments.of(OktaCustomScope.SITE_SYNC.getName(), new Role[] {Role.SECRET_ROTATION}), - Arguments.of(OktaCustomScope.SITE_SYNC.getName(), new Role[] {Role.SHARING_PORTAL}) + Arguments.of(OktaCustomScope.SITE_SYNC.getName(), new Role[] {Role.SHARING_PORTAL}), + Arguments.of(OktaCustomScope.CLIENT_KEY_ISSUANCE.getName(), new Role[] {Role.SUPER_USER}), + Arguments.of(OktaCustomScope.CLIENT_KEY_ISSUANCE.getName(), new Role[] {Role.PRIVILEGED}) ); } @@ -279,7 +281,8 @@ private static Stream testAccessTokenGoodData() { return Stream.of( Arguments.of(OktaCustomScope.SS_PORTAL, OktaCustomScope.SS_PORTAL.getRole()), Arguments.of(OktaCustomScope.SECRET_ROTATION, OktaCustomScope.SECRET_ROTATION.getRole()), - Arguments.of(OktaCustomScope.SITE_SYNC, OktaCustomScope.SITE_SYNC.getRole()) + Arguments.of(OktaCustomScope.SITE_SYNC, OktaCustomScope.SITE_SYNC.getRole()), + Arguments.of(OktaCustomScope.CLIENT_KEY_ISSUANCE, OktaCustomScope.CLIENT_KEY_ISSUANCE.getRole()) ); } diff --git a/src/test/java/com/uid2/admin/auth/OktaCustomScopeTest.java b/src/test/java/com/uid2/admin/auth/OktaCustomScopeTest.java index 400f3afc..e01fbf32 100644 --- a/src/test/java/com/uid2/admin/auth/OktaCustomScopeTest.java +++ b/src/test/java/com/uid2/admin/auth/OktaCustomScopeTest.java @@ -14,6 +14,7 @@ private static Stream testFromNameData() { Arguments.of("uid2.admin.ss-portal", OktaCustomScope.SS_PORTAL), Arguments.of("uid2.admin.secret-rotation", OktaCustomScope.SECRET_ROTATION), Arguments.of("uid2.admin.site-sync", OktaCustomScope.SITE_SYNC), + Arguments.of("uid2.admin.client-key-issuance", OktaCustomScope.CLIENT_KEY_ISSUANCE), Arguments.of("dummy", OktaCustomScope.INVALID) ); }