A disposable target system for practicing SailPoint SaaS Connectivity connector development.
Building a SaaS connector normally means standing something up to integrate with first — an Airtable base, a Discourse instance, a spare SaaS tenant. This removes that step. A developer opens the web app, clicks once for an API key, clicks again to seed realistic accounts and entitlements, and immediately has a live REST API to point a connector at.
Everything expires. An API key and all data beneath it are deleted 7 days after the key is created, enforced by DynamoDB TTL and re-checked on every request. It is a training sandbox and nothing else — never put real data in it.
A target system with the awkward edges real sources have. Not a toy that returns everything you ask for:
- Accounts paginate ten at a time, by opaque cursor. You have to page.
emailis withheld from list and read responses. You have to fan out a second call per account, the way the SailPoint docs' Discourse example does.- Group ids are opaque (
grp_...) and unrelated to group names. You have to resolve one to the other. PUTreplaces;PATCHmerges. Getting that wrong wipes attributes.permissionsare withheld unless you ask, mirroring theincludePermissionsschema flag.- 401, 403, 404, 400 and 409 are each reachable on purpose, so every
ConnectorErrorbranch has something to catch.
A command facade that shows you the answer. POST /v1/commands/{command} takes the same envelope the connector CLI sends and returns the documented Std*Output shape. Use it to see what your handler should have produced, then go and make it produce that. Both layers read the same data through the same service code, so they cannot disagree.
A web interface for reading and editing the data as a table, seeding a baseline, and copying a ready-made connector-spec.json pointed at your own sandbox.
API=<the ApiEndpoint output of the deployed stack>
# 1. Get a key. No signup, no credentials.
KEY=$(curl -sX POST "$API/v1/keys" | jq -r .apiKey)
# 2. Seed 50 accounts and 8 entitlements.
curl -sX POST "$API/v1/seed" -H "Authorization: Bearer $KEY" | jq
# 3. Aggregate. Note the absent email field and the cursor.
curl -s "$API/v1/users?limit=10" -H "Authorization: Bearer $KEY" | jq '.items[0], .cursor'
# 4. See what std:account:list should have returned.
curl -sX POST "$API/v1/commands/account-list" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"input":{}}' | head -2Send the key as Authorization: Bearer <key> or X-API-Key: <key> — the second exists so the connector SDK's apiKey auth type works unchanged.
Add ?readOnly=true when creating a key to get one that returns 403 on every write. It is the only practical way to exercise InsufficientPermissionError handling.
Shaped for teaching value rather than volume:
| 50 accounts | five pages at the default page size, so pagination is unavoidable |
| 8 entitlements | including one deprecated and one with no permissions |
| 4 disabled, 2 locked | so enable/disable/unlock have something real to toggle |
| 6 recently updated | so updatedSince delta aggregation returns a genuine subset |
| varied membership | including accounts with exactly one entitlement, the case that exposes the single-value-as-string quirk |
Seeding is idempotent. POST /v1/seed?reset=true replaces the data instead.
All fifteen, at POST /v1/commands/<segment>:
test-connection · account-list · account-read · account-create · account-update · account-delete · account-enable · account-disable · account-unlock · account-discover-schema · entitlement-list · entitlement-read · change-password · source-data-discover · source-data-read
The segments match the sailpoint conn invoke aliases. std:account:list url-encoded also works.
Two deliberate differences from the target-system layer:
- The facade always populates
email, because a correct connector would have made the second call to fetch it. account-listandentitlement-liststream newline-delimited{"data": ..., "type": "output"}objects, matching what a local connector run emits. Add?format=jsonfor an array. A statefulaccount-listappends a{"type": "state"}line showing the value to hand tores.saveState()— that trailing line is a convenience of this sandbox, not part of the SDK wire format.
openapi/saas-connectivity-demo.yaml — OpenAPI 3.0.3, covering both layers, with a worked example for every command.
It is documentation only; routes are defined in the SAM template. A test compares the two in both directions, so a route added without a spec entry (or vice versa) fails CI.
The deployed web app serves the spec at /openapi/saas-connectivity-demo.yaml and its API page renders copy-paste curl recipes pre-filled with your base URL.
CloudFront (OAC, SPA rewrite, CSP)
└── S3 bucket (private) ← Angular build, synced by CI
API Gateway HTTP API ($default stage, CORS, throttling)
└── ANY /{proxy+} → one Lambda (Hono router, Node 22 arm64, esbuild bundle)
└── DynamoDB single table (PK = API key, TTL = 7 days)
One Lambda rather than one per command. It keeps cold starts and IAM surface small, lets both API layers share one service layer, and means the whole API runs locally without the SAM CLI.
Data model. One table, one partition per API key:
| Item | PK | SK |
|---|---|---|
| Tenant metadata | TENANT#<apiKey> |
META |
| Account | TENANT#<apiKey> |
ACCOUNT#<id> |
| Entitlement | TENANT#<apiKey> |
ENTITLEMENT#<id> |
Every item carries the same ttl, so a key and all of its data expire together. No GSI — the access pattern is always tenant-scoped. Writing an account re-derives ttl from the tenant's expiry rather than extending it, so touching a record never prolongs its life.
Expiry is enforced twice. DynamoDB removes expired items within roughly 48 hours of the timestamp, not at it. Relying on the sweep alone would let a key keep working for two days past its advertised lifetime, so the auth middleware also rejects any tenant whose expiresAt has passed.
Passwords are never stored. change-password validates the value and discards it. The account's updated timestamp moves so delta aggregation still notices.
template.yaml SAM: HTTP API, Lambda, DynamoDB, S3, CloudFront
samconfig.toml
openapi/ OpenAPI 3.0.3 description of both layers
backend/ The API — TypeScript, Hono, bundled by esbuild
src/routes/ HTTP layer: users, groups, keys, seed, schema, commands
src/services/ Business logic, shared by both API layers
src/__tests__/ Vitest, incl. an in-memory DynamoDB fake
web/ Angular 20 SPA, Angular Material
.github/workflows/ ci.yml (PRs) and deploy.yml (push to main)
The SAM CLI is not needed. backend/src/local.ts serves the identical Hono app over HTTP, so the whole API is exercisable locally.
You need Java 17+ or Docker for DynamoDB Local.
# DynamoDB Local — Docker
docker run -d -p 8000:8000 amazon/dynamodb-local
# ...or the standalone Java archive, if you have no Docker
curl -sSfL -o ddb.tar.gz https://s3.us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.tar.gz
tar xzf ddb.tar.gz
java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -inMemory -port 8000
# The API on http://localhost:3000. Creates the table on startup.
cd backend && npm ci && npm run dev
# The web app on http://localhost:4200, pointed at localhost:3000 by default.
cd web && npm ci && npm startThe web app reads its API URL at runtime from web/public/config.json, so no rebuild is needed to repoint it.
npm run verify # from the repo root: format, spec, types, lint, tests, web build
cd backend && npm test # 168 tests, no network or Docker needed
cd web && npm run test:ci # headless Chrome
npx redocly lint # OpenAPIbackend/src/__tests__/fake-dynamo.ts is a Map-backed stand-in for the document client, so the whole API — routing, auth, pagination, seeding, the command facade — is testable without AWS. It deliberately reproduces two real DynamoDB behaviours: sort-key ordering, and returning a LastEvaluatedKey whenever a query stopped at the Limit rather than when more items exist. The second one matters: it means a full page is always followed by one more request, and the only safe stop condition is the absence of a cursor.
sam build cannot be run without the SAM CLI, so CI is the real check that the esbuild metadata and handler path are right.
.github/workflows/deploy.yml runs on every push to main and on manual dispatch. It assumes arn:aws:iam::176038645705:role/github-action-role in us-east-1 via GitHub OIDC — no long-lived access keys anywhere.
The workflow runs the API checks, builds and deploys the stack, reads the API URL back out of the CloudFormation outputs into web/public/config.json, builds and uploads the web app, invalidates the stable-named files in CloudFront, and finishes by creating a key, seeding it, asserting 50 accounts came back, and deleting it again. A broken deploy fails there rather than in front of a customer.
Nothing is deployed from a pull request. ci.yml runs the same checks with no AWS credentials configured, so a fork cannot reach the account.
Stack parameters:
| Parameter | Default | |
|---|---|---|
KeyTtlDays |
7 |
Capped at 30, so this deployment cannot become long-term storage. |
CorsAllowOrigins |
* |
Every request carries a bearer token the developer pastes in; there are no cookies and no ambient credentials to protect. Narrow it to the CloudFront domain to have the browser enforce origin too. |
- Deleting the stack needs the bucket emptied first. CloudFormation will not delete a non-empty S3 bucket:
aws s3 rm s3://<bucket> --recursivebeforesam delete. inlineCriticalis off inangular.json. Angular's critical-CSS inlining emits<link rel="stylesheet" media="print" onload="this.media='all'">, and the CloudFront CSP setsscript-src 'self', which blocks that inline handler and leaves the app with only the inlined subset of its styles. Turning the optimization off is cheaper than weakening the CSP for one attribute.esbuildis a runtime dependency, not a dev dependency, inbackend/package.json. SAM's esbuild builder runs a production-onlynpm installbefore bundling, so a dev dependency is not present when it looks.- The Lambda handler is
index.handler, notsrc/index.handler. SAM's esbuild builder flattens the bundle to the artifact root regardless of the entry point path. - Icons are bundled, not fetched from Google Fonts. The CSP restricts
font-srcandstyle-srcto'self', so an external font stylesheet is simply blocked.material-symbolsis served from the app's own origin.
MIT