Merged in feature/service-accounts-1 (pull request #227)

Support for service accounts with static credentials

* feature complete

* tests and docs

* lint fix
This commit is contained in:
Jay Brown
2026-05-21 19:40:04 +00:00
parent e3d9047143
commit 451da3d26d
63 changed files with 4347 additions and 11179 deletions
+16
View File
@@ -116,6 +116,22 @@ Usage:
## service.account.tool
Package main implements the service-account lifecycle CLI for the two-pool Cognito plan (plans/api.key.support.cognito.two-pool.plan.3.claude.md §4.5).
Subcommands:
Configuration comes from environment variables:
The tool is deliberately small — it wraps the AWS SDK and the Permit.io HTTP API directly and reuses the JSON shapes the user.creation.tool already proved work.
permit.go wraps the small slice of the Permit.io HTTP API the service-account CLI needs. We keep it self-contained rather than importing user.creation.tool's helpers because that package is a `main` and can't be reused.
## user.creation.tool
audit.go provides comprehensive audit trail logging functionality for compliance and monitoring. This file now uses the `log/slog` structured logging package introduced in Go 1.21. A single environment variable LOG_FORMAT controls the output format:
@@ -84,6 +84,11 @@ From `internal/serviceconfig/auth/config.go`:
- `PERMIT_IO_PDP_URL` (default `http://localhost:7766`)
- `COGNITO_ENVIRONMENT_ID` (optional, default empty) - Scopes this server instance to a specific environment within a shared Cognito user pool. When set, only users tagged with this environment ID (or untagged legacy users) are visible to admin operations and EULA compliance. Must be lowercase alphanumeric with underscores allowed in middle positions, max 50 characters. Empty string means no scoping (backward compatible).
Two-pool service accounts (optional; see [13-two-pool-service-accounts.md](13-two-pool-service-accounts.md)):
- `COGNITO_SVC_USER_POOL_ID` (default empty) - Cognito user pool id for service accounts ("pool B"). Empty disables the feature; queryAPI then validates tokens only against `COGNITO_USER_POOL_ID`. Setting this turns on multi-issuer validation.
- `COGNITO_SVC_JWKS_URL` (default derived) - JWKS endpoint for pool B. When unset, queryAPI computes `https://cognito-idp.<COGNITO_REGION>.amazonaws.com/<COGNITO_SVC_USER_POOL_ID>/.well-known/jwks.json` at startup. Override only for testing against a fake JWKS server.
- `COGNITO_SVC_APP_CLIENT_ID` (default empty) - App client id for pool B. Consumed by the `cmd/auth_related/service.account.tool` CLI and by service-account callers issuing `InitiateAuth`; queryAPI itself does not read it at runtime.
Auth path customization:
- `COGNITO_LOGIN_PATH` (default `/login`)
- `COGNITO_CALLBACK_PATH` (default `/login-callback`)
@@ -19,6 +19,8 @@ This document provides technical instructions for frontend (UI) developers integ
The Query Orchestration API uses **JWT (JSON Web Token) authentication** with tokens issued by **AWS Cognito**. The authentication flow assumes that the UI application handles user login directly with Cognito and obtains the necessary tokens.
> **Service-account callers (non-UI processes):** queryAPI also accepts JWTs from a second Cognito user pool dedicated to service accounts. The flow is different (Cognito `InitiateAuth` with username/password instead of OAuth + PKCE + MFA) and the lifecycle is operator-driven via a CLI tool. See [13-two-pool-service-accounts.md](13-two-pool-service-accounts.md) for the full reference. Everything below in this guide describes the human-user (pool A) path.
### Token Types
When a user authenticates with Cognito, your UI receives three tokens:
@@ -0,0 +1,215 @@
# 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:
```dotenv
# 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.
```bash
# 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:
```text
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):
```dotenv
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:
```bash
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 |
+12
View File
@@ -87,6 +87,12 @@ The diagram below shows the full API layer architecture — from HTTP endpoints
- Cascade deletion behavior (no special steps needed)
- Permit.io deployment note
**[File Validity Checks](file.filters.md)**
- PDF-specific validation (signature, structure, per-page size, dimensions, DPI) and SHA-256 deduplication
- Per-format checks for DOCX, XLSX, PPTX, TIFF, JPEG, PNG, BMP, TXT, EML
- Common pre-pipeline checks (extension allowlist, ZIP-archive limits, OLE2-encryption sniff)
- Failure-reason catalog (`InvalidDocumentReason`)
12. **[Chatbot Guide](12-chatbot-guide.md)**
- Overview: queryAPI's facade over the Aaria FastAPI agent service
- Architecture: `bot_*` data model, AddTurn tx1+FastAPI+tx2 flow, state stripping
@@ -96,6 +102,12 @@ The diagram below shows the full API layer architecture — from HTTP endpoints
- Rate-limit overrides: M6 behavior change and Echo `:param` template form
- Known warts and tradeoffs (CHECK guard, error chaining, label-record divergence, etc.)
13. **[Two-Pool Service Accounts](13-two-pool-service-accounts.md)**
- Multi-issuer JWT validation (pool A + pool B) and `auth_source` tagging
- `COGNITO_SVC_USER_POOL_ID` / `COGNITO_SVC_JWKS_URL` / `COGNITO_SVC_APP_CLIENT_ID` configuration
- Service-account lifecycle CLI (`cmd/auth_related/service.account.tool`)
- Caller flow: `InitiateAuth` + bearer JWT, revocation strategy
## Quick Start
### For New Developers