Skip to content

Repository files navigation

SaaS Connectivity Web Demo

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.


What it gives you

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.
  • email is 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.
  • PUT replaces; PATCH merges. Getting that wrong wipes attributes.
  • permissions are withheld unless you ask, mirroring the includePermissions schema flag.
  • 401, 403, 404, 400 and 409 are each reachable on purpose, so every ConnectorError branch 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.

Quickstart

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 -2

Send 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.

The seeded dataset

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.

Commands supported

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-list and entitlement-list stream newline-delimited {"data": ..., "type": "output"} objects, matching what a local connector run emits. Add ?format=json for an array. A stateful account-list appends a {"type": "state"} line showing the value to hand to res.saveState() — that trailing line is a convenience of this sandbox, not part of the SDK wire format.

API reference

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.

Architecture

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.

Repository layout

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)

Local development

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 start

The web app reads its API URL at runtime from web/public/config.json, so no rebuild is needed to repoint it.

Checks

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               # OpenAPI

backend/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.

Deployment

.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.

Notes for whoever maintains this

  • Deleting the stack needs the bucket emptied first. CloudFormation will not delete a non-empty S3 bucket: aws s3 rm s3://<bucket> --recursive before sam delete.
  • inlineCritical is off in angular.json. Angular's critical-CSS inlining emits <link rel="stylesheet" media="print" onload="this.media='all'">, and the CloudFront CSP sets script-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.
  • esbuild is a runtime dependency, not a dev dependency, in backend/package.json. SAM's esbuild builder runs a production-only npm install before bundling, so a dev dependency is not present when it looks.
  • The Lambda handler is index.handler, not src/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-src and style-src to 'self', so an external font stylesheet is simply blocked. material-symbols is served from the app's own origin.

Licence

MIT

About

Demo application for SaaS Connectivity

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages