App user management (the signed connector)
SaaS Pro Max manages the users of your application without ever holding them. There is no shared database, no export, no sync job. The console calls a small HTTP surface you mount inside your own backend, signs every request with a key pair that exists for exactly one application, and stores only its own record of what an operator asked for.
The tenant half of the contract is @saaspro/node and is documented in
docs/sdk/connector.md. This page is the platform half:
what the console sends, what it stores, and what an operator can do.
Security model
Every call carries Authorization: Bearer <EdDSA JWT>:
{
"iss": "saaspromax",
"aud": "<application id>",
"sub": "<console user id>",
"email": "operator@example.com",
"orgId": "<organization id>",
"appId": "<application id>",
"permissions": ["people.read", "people.pii.read"],
"iat": 1788000000,
"nbf": 1787999995,
"exp": 1788000060,
"jti": "1f0c…"
}
Five properties make this safe to point at someone else's production system:
- The token is scoped to one route.
permissionsis not the caller's whole role — it is the intersection of what the operator holds with what the route needs. A prompt sync carriesevals.readand nothing else. - Asserting an unheld permission is an error, not a downgrade. If the acting
member does not hold the permission a route needs, the request is refused
before it leaves the platform (
ConnectorErrorwith kindforbidden). - PII is opt-in per route.
people.pii.readis forwarded only on routes that carry user records (/overview,/users,/users/{id},/users/{id}/controls,/communications/send) and only when the operator holds it. Without it the tenant handler stripsemailandnamefrom every user in every response. - Tokens live 60 seconds and are single-use.
jtiis a fresh UUID and the tenant handler keeps a two-minute replay window. - The private key never leaves the vault. It is generated in the console, envelope-encrypted immediately, and decrypted server-side for the duration of one signature.
The token identifies a person (sub, email), so your own audit log can record
who acted, not just "the platform".
Key lifecycle
| Step | What happens | Where the material lives |
|---|---|---|
| Generate | Ed25519 key pair created in the console | private key → vault (provider_credentials, provider connector), public key → app_connectors.public_key |
| Install | Operator copies the public key into the application | your environment: SPM_CONNECTOR_PUBLIC_KEY, SPM_APP_ID |
| Health | GET /health probes the handler and records its capabilities |
app_connectors.capabilities, status, last_health_at, last_error |
| Rotate | A new pair is generated; the old one moves to previous_* with a 24 h expiry |
both private keys in the vault |
| Expire | After 24 h the superseded credential is revoked by the worker | vault row status='revoked' |
Rotation is a 24-hour window, not a swap. The tenant handler only ever knows
one public key. During the grace window the console signs with the new key,
and if the tenant answers 401 it retries the same request once with the
superseded key. Update SPM_CONNECTOR_PUBLIC_KEY in your application inside
that window; once it lapses the old vault credential is revoked and the fallback
stops working — every request then fails with unauthorized until the tenant
has the current key.
Environment variables the tenant sets:
SPM_CONNECTOR_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA…\n-----END PUBLIC KEY-----"
SPM_APP_ID="<application id>"
Both PEM (SPKI) and a bare base64 32-byte key are accepted by the SDK. The
settings page shows the exact block to paste, plus a .pem download.
Permissions forwarded per route
| Console call | Route | Permission asserted | PII forwarded |
|---|---|---|---|
connectorHealth |
GET /health |
(none) | no |
connectorOverview |
GET /overview |
people.read |
when held |
connectorUsers |
GET /users |
people.read |
when held |
connectorUser |
GET /users/{id} |
people.read |
when held |
applyControl |
POST /users/{id}/controls |
people.controls.write |
when held |
connectorPrompts |
GET /prompts |
evals.read |
no |
reportGate |
POST /prompts/{slug}/gate |
evals.run |
no |
agentContext |
POST /agent-context |
evals.read |
no |
sendCommunication |
POST /communications/send |
communications.write |
when held |
Console permissions for the module itself: the directory and dossier need
people.read; unmasked emails need people.pii.read; controls need
people.controls.write; everything on Settings › Connector needs apps.write
and a passkey verified in the last ten minutes.
Controls and approvals
An account control is something the console asks the application to do to one
user — suspend it, force a password reset, erase its data. The tenant decides
what exists by returning availableControls on the dossier:
{ type: "suspend", label: "Suspend account", description: "Blocks sign-in",
reversible: true, maxDays: 30 }
{ type: "delete_data", label: "Erase account data", description: "…",
reversible: false, requiresApproval: true }
The console validates the submitted control against that list server-side —
unknown type, a duration over maxDays, or removing an irreversible control are
all refused before anything is sent. Every request needs a reason of at least
ten characters and a fresh passkey, and is written to the audit ledger.
requiresApproval turns the request into a four-eyes flow:
operator submits → connector_controls row (pending_approval)
+ approval_requests row (action "connector.control")
second operator approves → operations page records the decision
executeApprovedControl() → tenant call with the ORIGINAL requestId
→ connector_controls row (applied) + audit event
The requestId is minted when the request is recorded and reused when it
finally executes, so a tenant that keys on it is idempotent even when the
approval lands hours later. executeApprovedControl(approvalId) claims
approval_requests.executed_at before calling out, so two callers racing on the
same approval produce exactly one tenant call. A rejected or expired approval
turns the ledger row into rejected.
Two things drive the executor: the operations approval handler (once it calls
executeApprovedControl) and the recurring connector.controls job, which is
the safety net for a decision whose request died before dispatch.
Storage
| Table | Holds |
|---|---|
app_connectors |
one row per application: url, key_id, public_key, private_key_credential_id, issuer, status, capabilities, version, last_health_at, last_error, rotated_at, and the previous_* columns of the rotation grace |
connector_controls |
the platform ledger: tenant user, type, action, reason, duration, request_id, approval_request_id, status, the tenant's response, the acting operator |
status on a connector moves unconfigured → configured → healthy | unhealthy
(disabled switches it off). No tenant user record is ever stored. The
directory and dossier are rendered from a live response and nothing is cached;
a control's ledger row keeps the tenant user id, which is the only identifier
the platform retains.
Jobs
| Kind | Every | Does |
|---|---|---|
connector.health |
10 min | probes every configured connector, records status and capabilities, revokes rotated keys past their grace window |
connector.controls |
1 min | executes approved controls that have not reached the tenant, marks rejected ones |
Neither job notifies anyone: a connector going quiet is shown on the settings page and in the directory, not paged on.
Using the connector from another module
src/lib/connector/client.ts is the only entry point. It takes a context, not a
request, so it works from a page, an API route and the worker alike:
type ConnectorContext = {
org: { id: string };
app: { id: string };
user: { id: string; email: string };
permissions: readonly string[];
};
connectorFetch<T>(ctx, path, { scope, includePii?, timeoutMs?, ...RequestInit }): Promise<T>
An AppContext from requireApp satisfies ConnectorContext structurally.
Outside a request, build one with connectorContextForApp(appId, userId), which
re-reads the actor's membership so a removed member cannot keep acting through
a background job.
- Evals (W11) call
connectorPrompts(ctx),reportGate(ctx, slug, payload)andagentContext(ctx, input). None of them forward PII. - Communications (W12b) call
sendCommunication(ctx, payload)for thein_appchannel. It is user-bearing, so PII is forwarded when the acting member holdspeople.pii.read.
Failures are ConnectorError with a kind of unconfigured, network,
unauthorized, forbidden or upstream, plus the HTTP status when there was
one. Pages render the kind as a sentence through connectorErrorHint; a module
that cannot recover should surface the message rather than swallow it.
Operator checklist
- Settings › Connector: URL saved (https outside localhost, no query string)
- Key pair generated, public key and app id installed in the application
- Health check returns the capabilities you expect
-
applyControlis idempotent onrequestIdin your handler - Control changes are written to your own audit log with
ctx.claims.sub - After a rotation, the application's environment is updated within 24 hours
What the assistant can do here
Nothing, deliberately. The connector's controls — suspending an account, resetting a password, forcing a sign-out — are console actions guarded by the key pair and the approvals described above, and they act inside your backend rather than on data SaaS Pro Max holds. There is no management API surface for them, so the assistant has no tool for them and will say so.
What it can do is explain: it reads this documentation directly, so "what does the connector sign, and what does it store?" is answered from the page you are reading rather than from memory.