Documentation menu

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:

  1. The token is scoped to one route. permissions is not the caller's whole role — it is the intersection of what the operator holds with what the route needs. A prompt sync carries evals.read and nothing else.
  2. 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 (ConnectorError with kind forbidden).
  3. PII is opt-in per route. people.pii.read is 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 strips email and name from every user in every response.
  4. Tokens live 60 seconds and are single-use. jti is a fresh UUID and the tenant handler keeps a two-minute replay window.
  5. 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) and agentContext(ctx, input). None of them forward PII.
  • Communications (W12b) call sendCommunication(ctx, payload) for the in_app channel. It is user-bearing, so PII is forwarded when the acting member holds people.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
  • applyControl is idempotent on requestId in 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.