Files
query-orchestration/docs/ai.generated/13-two-pool-service-accounts.md
T
Jay Brown 451da3d26d Merged in feature/service-accounts-1 (pull request #227)
Support for service accounts with static credentials

* feature complete

* tests and docs

* lint fix
2026-05-21 19:40:04 +00:00

9.4 KiB

Two-Pool Service Accounts (Cognito)

Reference for the multi-issuer authentication path introduced by the api.key.support.cognito.two-pool.plan.3.claude.md plan. Human users keep authenticating through the existing Cognito user pool ("pool A"); machine callers authenticate through a second user pool ("pool B") that has MFA disabled. queryAPI accepts JWTs from either pool with no other code changes for the consuming caller.

This page covers what changed in queryAPI, the operator-side setup, the lifecycle CLI, and what to verify when something breaks.

What the feature changes

Surface Pool A (humans) Pool B (service accounts)
Cognito user pool unchanged (COGNITO_USER_POOL_ID) new (COGNITO_SVC_USER_POOL_ID)
JWKS COGNITO_JWKS_URL (derived) COGNITO_SVC_JWKS_URL (derived)
Caller flow OAuth + PKCE + MFA via hosted UI InitiateAuth USER_PASSWORD_AUTH
iss claim https://cognito-idp.<region>.amazonaws.com/<pool-A-id> .../<pool-B-id>
auth_source (echo ctx) "user" "service"
Env check (CheckUserEnvironmentAccess) looks up sub in pool A looks up sub in pool B
EULA agreement required on first login not applicable (service accounts cannot interactively agree)
Permit.io user keyed on pool A sub, attribute is_service_account absent keyed on pool B sub, attribute is_service_account=true
Lifecycle cmd/auth_related/user.creation.tool cmd/auth_related/service.account.tool

The feature is off by default. With COGNITO_SVC_USER_POOL_ID empty, queryAPI rebuilds the keyset map with only pool A and the runtime behavior is bit-identical to pre-change.

Configuration

Set these in .env (or the equivalent secret store) when enabling pool B for an environment:

# Required to turn the feature on. queryAPI derives the JWKS URL from
# this id at startup. The CLI tool refuses to run when this is empty.
COGNITO_SVC_USER_POOL_ID=us-east-2_Qom1i9qIG

# Optional. Defaults to the cognito-idp.<region>.amazonaws.com URL
# computed from COGNITO_SVC_USER_POOL_ID + COGNITO_REGION. Override
# only for tests that point at a fake JWKS endpoint.
COGNITO_SVC_JWKS_URL=

# Only the CLI tool and consuming callers need this. queryAPI itself
# does not call InitiateAuth — it only validates tokens — so it is
# safe to omit on queryAPI hosts.
COGNITO_SVC_APP_CLIENT_ID=6r7chkvjotgs2bsfbd5ibaaft7

The full template is in .env.example. The provisioning scripts in cmd/auth_related/service.accounts/creation.scripts/ print the correct values for the dev environment.

Caller flow

A consuming process needs (username, password, app-client-id) plus the pool id. It does not need AWS IAM credentials — InitiateAuth is an unauthenticated Cognito endpoint.

# 1. Get an access token. Refresh ~10 min before exp.
aws cognito-idp initiate-auth \
  --region "$COGNITO_REGION" \
  --client-id "$COGNITO_SVC_APP_CLIENT_ID" \
  --auth-flow USER_PASSWORD_AUTH \
  --auth-parameters USERNAME="$SVC_USERNAME",PASSWORD="$SVC_PASSWORD"

# 2. Call queryAPI with the access token. The token shape is
#    identical to a human token: sub, iss, username, exp, ...
curl https://queryapi.example.com/clients \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# 3. When near expiry, refresh.
aws cognito-idp initiate-auth \
  --region "$COGNITO_REGION" \
  --client-id "$COGNITO_SVC_APP_CLIENT_ID" \
  --auth-flow REFRESH_TOKEN_AUTH \
  --auth-parameters REFRESH_TOKEN="$REFRESH_TOKEN"

For a zero-dependency reference implementation in Python, see cmd/auth_related/service.accounts/test.scripts/test.service.account.auth.py.

How queryAPI authenticates two issuers safely

internal/cognitoauth/token.go builds an IssuerKeysets map at the start of each verifier call: pool A always present, pool B added when COGNITO_SVC_USER_POOL_ID is set. Verification is a two-pass:

  1. Peek at the token's iss claim without trusting it (no signature check). The peek's only job is to pick a keyset.
  2. Re-parse the token with jwt.WithKeySet(keySet) and jwt.WithIssuer(iss). A forged iss therefore fails because the chosen pool's JWKS does not contain the signer's key.

Anything whose iss is not in the map is rejected with "untrusted issuer: ...". The JWKS cache (internal/cognitoauth/jwks.go) is keyed by URL, so the two pools never clobber each other's cached keys.

Service-account lifecycle CLI

cmd/auth_related/service.account.tool wraps the Cognito + Permit calls needed to manage pool B users from an operator workstation. Source layout mirrors user.creation.tool.

Subcommands:

service.account.tool create <username>          [--description text] [--env-id id] [--roles r1,r2]
service.account.tool show <username>
service.account.tool list
service.account.tool disable <username>          # AdminDisableUser + AdminUserGlobalSignOut
service.account.tool enable <username>           # AdminEnableUser
service.account.tool rotate-password <username>  # AdminSetUserPassword (permanent)
service.account.tool delete <username>           # AdminDeleteUser + DeletePermitUser
service.account.tool register-permit <username> [--roles r1,r2]
        # Registers an already-existing Cognito service-account in
        # Permit and assigns roles. Use this when the Cognito user was
        # created outside the tool (e.g. by the bring-up shell scripts
        # in cmd/auth_related/service.accounts/creation.scripts/).

Env vars (the wrapper run.tool.sh sets the dev defaults):

COGNITO_REGION=us-east-2
COGNITO_SVC_USER_POOL_ID=us-east-2_Qom1i9qIG
COGNITO_SVC_APP_CLIENT_ID=6r7chkvjotgs2bsfbd5ibaaft7
PERMIT_IO_API_KEY=...
PERMIT_IO_BASE_URL=https://api.permit.io
AWS_PROFILE=...

The create command:

  • prints the password once;
  • stores nothing local (you must copy it into your secret store);
  • registers the user in Permit with is_service_account=true;
  • assigns any roles passed via --roles.

If PERMIT_IO_API_KEY is unset, the create flow logs a warning and skips the Permit step — useful for offline dry-runs but not what you want in production.

EULA

queryAPI does not currently enforce EULA acceptance with a middleware gate — the EULA endpoints (/eula/agree, /eula/status) are self-service and surface status. Service accounts simply do not call them. If a future change introduces an EULA-enforcement middleware, the right hook is auth_source == "service" → skip; the value is already on the echo context by then.

Revocation

Cognito does not provide instant revocation for outstanding access tokens. The mitigation is two layers:

  1. Bound the lag with the pool B AccessTokenValidity setting (60 minutes by default; can drop to 5 minutes per the plan).
  2. Operator runs service.account.tool disable <username>:
    • AdminDisableUser blocks new auth + refresh attempts.
    • AdminUserGlobalSignOut revokes all refresh tokens.
    • Outstanding access tokens remain valid until exp.

If sub-minute revocation is ever required, the follow-up is an in-service deny list keyed on sub for auth_source == "service". It is deliberately not in scope for v1.

Testing locally

Unit tests covering the multi-issuer paths live in:

  • internal/cognitoauth/multi_issuer_test.go (verifier accepts pool A + pool B tokens, rejects forged iss, rejects unknown issuers)
  • internal/cognitoauth/envcheck_test.go (CheckUserEnvironmentAccess dispatches on auth_source; rejects when auth_source=service but pool B is unconfigured)
  • cmd/auth_related/service.account.tool/main_test.go (password class invariants, CSV splitter)

Run the targeted set with:

go test ./internal/cognitoauth/ -run TwoPool
go test ./cmd/auth_related/service.account.tool/

For an end-to-end smoke test against the dev pool B, see cmd/auth_related/service.accounts/test.scripts/test.service.account.auth.py.

Lifecycle coverage (disable / enable / rotate-password / delete) lives in scripts/manual_tests/test_two_pool_lifecycle.py. It uses a throwaway user (SVC_LIFECYCLE_USERNAME, default svc-lifecycle-test-1) so it never collides with the long-lived super-admin test account.

Both manual scripts read credentials from the env vars documented in .env.example under "Pool B (service-accounts) test credentials" (SVC_TEST_USERNAME, SVC_TEST_PASSWORD, SVC_TEST_SUB, SVC_TEST_USER_POOL_ID, SVC_TEST_APP_CLIENT_ID). A repo-root .env is auto-loaded; no third-party dotenv library is required.

When something breaks

Symptom Likely cause Where to look
All requests return 401 right after enabling pool B Wrong COGNITO_SVC_JWKS_URL or pool B JWKS endpoint unreachable loadIssuerKeysets in middleware.go; check server startup logs for "loading service pool JWKS"
Service token returns 403 from env check Pool B sub not registered in Permit.io with the right environment_id and roles service.account.tool register-permit <username>
"untrusted issuer" warnings Token issued by a Cognito pool that is not configured Verify the token's iss matches one of the two configured pools
"untrusted issuer" for a known pool right after rollout Pool B JWKS not loaded because COGNITO_SVC_USER_POOL_ID is empty Confirm env var presence with a printenv | grep COGNITO_SVC
EULA endpoint returns "Insufficient permissions" for service token Service account does not have a role with eula action permission Either grant the role in Permit, or do not call EULA endpoints from service code