8 min read

Reversible SaaS user provisioning for AI agents

Design reversible SaaS user provisioning for AI agents by separating invitations, roles, and groups, with safe retries and useful audit records.

Reversible SaaS user provisioning for AI agents

An AI agent should never provision a SaaS user with one opaque instruction such as "add Priya to the company account with the normal engineering access." That sentence hides at least three state changes: create or invite an identity, assign an account-level role, and add group memberships. Each change has a different risk, completion condition, and undo operation.

Reversible SaaS user provisioning starts by preserving those boundaries. Let the agent propose a sequence, execute one call at a time, record the returned identifiers, and stop when observed state differs from expected state. This costs a few more API calls. It saves the operator from reconstructing a half-finished access grant after a timeout, a wrong email address, or an overbroad group assignment.

Invitation, role, and membership are different states

A pending invitation is not a user, a user is not a role, and a role is not a group membership. Provisioning systems routinely blur these objects because vendor consoles present them in one form. The API underneath often exposes different resources or lifecycle operations, and the distinction decides whether an agent can recover safely.

An invitation usually represents intent plus a delivery or redemption process. The recipient may need to accept it, may use a different identity than expected, or may never respond. Microsoft Graph documents this explicitly for external users: creating an invitation returns an invitation object, while the invited person completes an interactive redemption flow. GitHub organization membership also remains pending until the person accepts. An agent that logs "user created" immediately after either invitation has recorded a hope, not a fact.

A role changes authority at the account or organization level. It may make someone an owner, billing manager, administrator, guest, or ordinary member. A group membership usually grants access indirectly to projects, repositories, channels, applications, or shared data. Removing the group can revoke that indirect access without changing the account role. Demoting the role may leave group-derived privileges untouched.

Model the states separately, even when a vendor offers a convenience endpoint that accepts all three in one POST. A sensible internal record looks like this:

{
  "subject": "[email protected]",
  "invitation": {"state": "pending", "id": "inv_8421"},
  "role": {"desired": "member", "observed": null},
  "groups": {
    "desired": ["engineering", "on-call-readers"],
    "observed": []
  }
}

That separation answers the awkward operational question: what exactly must be undone? If the invitation exists but no account has been redeemed, cancel the invitation. If the account exists with the wrong role, restore the previous role. If one group was added and the next call failed, remove only the membership created by this run. Deleting the whole user is usually a reckless substitute for knowing which state changed.

The first design rule is therefore simple: one journaled action should correspond to one remotely observable state transition. A call can still produce vendor side effects such as an email, but the agent and operator should be able to name the primary transition without saying "and then it also..."

A provisioning plan must be data, not prose

The agent should compile a human request into a typed plan before it calls the vendor. A plan makes ambiguous assumptions visible and gives the executor stable inputs. Free-form reasoning belongs before execution; the execution boundary should receive boring data.

At minimum, the plan needs a subject identifier, target tenant, invitation mode, requested role, requested groups, preconditions, and an operation identifier. It should also state whether the action may send email. Invitation delivery is an external side effect that cancellation cannot retract, so hiding it behind a default is poor practice.

{
  "operation_id": "prov_2026_07_24_0187",
  "tenant": "acme-production",
  "subject": {"email": "[email protected]"},
  "steps": [
    {"kind": "invite", "send_email": false},
    {"kind": "wait_for_acceptance"},
    {"kind": "set_role", "role": "member"},
    {"kind": "add_group", "group": "engineering"},
    {"kind": "add_group", "group": "on-call-readers"}
  ],
  "preconditions": {
    "account_absent": true,
    "allowed_email_domain": "example.test"
  }
}

Keep group additions as separate steps rather than a single array passed to a broad endpoint. The executor can then approve, retry, and compensate for each membership independently. The plan also reveals ordering. If the vendor cannot assign a role until acceptance, wait_for_acceptance is a genuine state gate, not a sleep instruction.

Validate the plan against local constraints before exposing a credential or making a network call. Check that the tenant is an exact known identifier, normalize the email domain without rewriting the local part, resolve human group names to immutable vendor IDs, and reject an owner or administrator role unless the request explicitly names it. Do not let the agent discover tenant identifiers by searching across every account available to a powerful token.

Preflight reads should capture existing state. Search for the subject by the vendor's documented unique attribute, then fetch direct role and membership records. Distinguish "not found" from "read failed." A 403, timeout, or truncated page does not prove absence. If the search is eventually consistent or paginated, record that limitation and require a stronger lookup before creating anything.

The plan should freeze after approval. If the agent changes an email, role, group ID, or delivery flag, generate a new operation ID and request a new decision. Otherwise the approved sentence and the executed calls can quietly diverge.

Stage the invitation before granting access

Create or send the invitation first, then stop until the service proves what happened. Do not bundle privileged roles and sensitive groups into the invitation just because the endpoint accepts them. The popular argument for bundling is efficiency: one request looks atomic and sends one notification. In practice, most SaaS APIs do not promise a transaction across identity creation, role assignment, notification, and group propagation.

GitHub's organization invitation endpoint illustrates the temptation. Its request can include a role and team IDs. That is convenient for an owner using a console, but an autonomous executor loses useful checkpoints when it submits all of them together. A validation error may reject everything, while a response lost after acceptance leaves the executor unsure which effects occurred. A later human acceptance can also activate access long after the agent run ended.

Prefer the least authoritative invitation the vendor permits. If an invitation must carry a role, use the ordinary member role and defer elevation. If it must include a group or channel, choose a holding group with no sensitive resources, then add intended memberships after the identity reaches the expected state. Some APIs constrain this strategy. Slack's documented Enterprise Grid invitation method, for example, requires at least one channel ID. That is a reason to define a low-access arrival channel, not a reason to attach every working channel to the first call.

Record whether the vendor sent mail and capture the invitation ID, status, creation time, and canonical subject returned by the service. Do not store a redemption URL in a broad journal because it may function as a bearer capability. If the API returns one for separate delivery, route it through the smallest trusted component and redact it from agent-visible results.

A staged invitation needs an explicit terminal condition. Use accepted, expired, cancelled, and pending if the vendor exposes them. If it does not, derive the state from documented user or membership fields and label the value as derived. Never interpret "invitation POST returned 201" as "recipient can now access production data."

Set a deadline in the local operation record. When the deadline passes, inspect current state and cancel a still-pending invitation if the business request no longer applies. Do not schedule blind cancellation: the invitee may have accepted just before the timer fired. Read, compare, then act.

Invitation cancellation is reversible only in a narrow sense. It prevents later acceptance when the service supports cancellation, but it cannot unsend email or erase knowledge of the organization. Write that limit into the approval card. Operators make better decisions when "reversible" describes the remote authorization state rather than pretending every consequence can be erased.

Assign roles only after identity is settled

Role assignment should wait until the agent can bind the request to a stable remote user ID. Email addresses are useful for discovery, but they are poor long-term handles. People change addresses, aliases collide, and invited users sometimes redeem through an existing account. The vendor's returned user ID should become the subject of later calls.

Before changing a role, read the current role and store it as the compensation value. If the desired value already matches, record a no-op instead of issuing another write. A no-op is useful evidence: it proves the executor checked the condition and did not take credit for a change that another actor had already made.

Treat elevation differently from ordinary membership. An agent may assign a standard member role under session approval while requiring per-call approval for owner, administrator, or billing authority. The control boundary should follow the consequence of the credential use, not the HTTP method. PATCH /users/123 can be routine or catastrophic depending on one field.

Use compare-and-set controls when the API provides them. An ETag with If-Match, a version field, or a vendor-specific revision prevents the agent from overwriting a human change made after preflight. If the service returns a conflict, read the state again and stop for review. Do not fetch the new value and immediately force the original plan; the concurrent change may be the fact the operator needs to see.

The role journal entry should include the previous value, requested value, observed value, remote subject ID, response status, and any concurrency token. It should not include the bearer token used for the call. A minimal success record can look like this:

{
  "operation_id": "prov_2026_07_24_0187",
  "step": 3,
  "action": "role.set",
  "subject_id": "usr_1938",
  "before": "guest",
  "requested": "member",
  "observed": "member",
  "http_status": 200,
  "undo": {"action": "role.set", "value": "guest"}
}

Do not call a role change complete solely because the update returned success. Fetch the user or membership resource and confirm the effective value. Vendor APIs may return 202 Accepted, apply changes asynchronously, or expose separate pending and active records. The journal needs to say requested until a read proves observed.

When demotion is the compensation, consider whether demotion itself requires approval. Restoring guest after an accidental owner grant is usually safer than waiting, but automated rollback can conflict with a human correction. Define the rule ahead of time: automatic compensation is allowed only while the stored version still matches the version created by this operation. Otherwise stop and show the divergence.

Add each group as its own call

Stop a half-finished agent run
Revoke the active session before a partially failed provisioning plan makes its next call.

Every group membership should have its own step, remote target ID, result, and undo instruction. Groups are where broad access often hides. A name such as engineering can control source repositories, deployment consoles, incident channels, and downstream application assignments through synchronization. The provisioning agent should not infer that blast radius from the friendly name.

Resolve groups from an allowlisted catalog maintained outside the agent's prompt. The catalog should map a display name to a tenant-scoped immutable ID and describe whether membership is direct, nested, dynamic, or synchronized. If two groups share a display name, fail closed. If the group is rule-managed, do not fight the rule with repeated direct writes.

The Google Admin SDK Directory API makes the resource boundary concrete. It exposes one endpoint to add a group member, another to update that membership, and a DELETE endpoint to remove it. Its documentation also warns that nested group membership can take time to appear and rejects membership cycles. Those details argue for observed-state checks rather than a loop that assumes immediate consistency.

SCIM offers another useful behavior. RFC 7644's PATCH example for adding a member says that when the user already belongs to the group, the server should make no change and return success. That is friendly retry behavior, but do not assume every SCIM implementation follows the example perfectly. Test the provider and keep the lookup path for reconciliation.

A group step should distinguish four outcomes: added, already_present, rejected, and unknown. Already_present must not create an undo entry, because removing that membership during rollback would erase access that predated the operation. Unknown means the write may have succeeded but the response was lost or the verification read failed. It requires reconciliation, not an optimistic retry.

Process groups in increasing privilege order. Add the basic collaboration group before the production administration group. This does not make a failed run harmless, but it leaves the subject with the smaller access set when execution stops. Require a fresh approval at any group boundary marked sensitive, even if earlier ordinary groups succeeded.

Do not parallelize membership writes merely to reduce latency. Parallel calls scramble evidence, complicate rate-limit handling, and may trigger downstream provisioning in an order you cannot predict. A few sequential calls are cheap compared with diagnosing why an identity received an application license before its restricted-data agreement was recorded.

After each add, read the direct membership resource rather than a flattened effective-membership view. Effective membership may arise through a parent group and may remain true after the direct edge is removed. Your rollback receipt must name the edge this operation created.

Safe retries begin with observed state

A retry policy cannot turn an arbitrary POST into a safe operation. RFC 9110 defines PUT, DELETE, and safe methods as idempotent by their intended effect. It also says a client should not automatically retry a non-idempotent request unless it knows the semantics are idempotent or can tell that the original request was never applied. Provisioning code should take that warning literally.

The dangerous case is a timeout after sending an invitation POST. The server may have created the invitation and sent mail before the connection failed. Repeating the POST can create a second invitation or a second notification. The correct next action is a read using the operation's subject and tenant, followed by one of three decisions: adopt the matching remote object, retry only when absence is proven, or stop when the result is ambiguous.

Use a vendor idempotency key when the API documents one. Derive it from the immutable operation ID and step number, store it in the journal, and reuse it for the same logical attempt. Never generate a new key merely because a request timed out. A new key tells the server that the retry is a new action.

When no idempotency key exists, build a reconciliation function for each write before giving that write to an agent. The function must know how to find the created object without fuzzy matching. Email plus tenant may identify a pending invitation, while user ID plus group ID identifies a membership edge. If the API cannot support an exact lookup, classify the write as requiring human confirmation after an uncertain result.

Retries also need a budget. Honor Retry-After when present, apply bounded backoff for transient server errors, and stop on validation, permission, or conflict errors. A 403 is not a slow 200. Repeatedly presenting the same denied call also trains operators to approve prompts without reading them.

A reliable executor uses this decision table:

ResultNext action
Definite success and verified stateCommit the step receipt
Definite failure with no state changeRecord failure and stop
Timeout after request transmissionReconcile before any retry
Success response but verification differsRecord divergence and stop
Rate limit with retry guidanceWait within the retry budget

Keep transport attempts separate from logical steps. Five HTTP attempts may correspond to one membership addition. The main journal entry should show the logical result, while linked attempt records preserve status codes and timing. Otherwise an audit reader may mistake repeated attempts for repeated grants.

Rollback is compensation, not time travel

Lock the provisioning credential
The vault denies every action while locked and uses Secure Enclave plus Touch ID on macOS.

SaaS provisioning rarely supports a distributed transaction, so rollback means applying compensating actions in reverse order. Remove memberships created by the run, restore the prior role, then cancel the invitation if it remains pending. Each compensation is another real API call that can fail, require approval, or encounter concurrent edits.

Build the compensation stack from confirmed changes, not from planned steps. When a group was already present, there is nothing to remove. When a role update never reached the server, there is nothing to restore. When verification is unknown, do not guess. Reconcile before deciding whether to add a compensation entry.

A useful receipt stores enough data to attempt and limit the undo:

{
  "action": "group.add",
  "target": {"user_id": "usr_1938", "group_id": "grp_77"},
  "result": "added",
  "remote_version_after": "W/\"9012\"",
  "compensation": {
    "action": "group.remove",
    "only_if_direct_membership_matches": true
  }
}

The version guard matters. Suppose the agent adds Priya to a group, then a manager independently confirms that membership in the admin console. A blind rollback can remove an access decision that now has a separate owner. Some services cannot express this condition in the delete request. In that case, read the current edge and its metadata, show the conflict, and ask a person to decide.

Not every side effect has a compensation. Email cannot be recalled. An audit event should not be deleted. A license allocation may affect billing even if the membership is later removed. A downstream identity provider may propagate a group change after the source edge disappears. Mark these as residual effects in the operation result instead of calling the rollback complete without qualification.

Rollback should also have a deadline and escalation path. Credentials can expire, the service can be unavailable, and the original agent process can exit. Persist receipts outside the agent's context so another trusted executor can continue. The operator needs to see rollback_pending, not a cheerful failure message buried in a transcript.

Test compensation against a nonproduction tenant with real API behavior. Create a pending invitation, cancel it, and verify its acceptance link fails. Add and remove a direct group edge, then inspect effective access. Change a low-risk role and restore it under a concurrency conflict. Documentation describes intended semantics; these drills reveal what the service actually exposes.

Journal entries must prove cause and effect

A useful journal answers who requested the change, which agent process executed it, what credential boundary authorized it, which remote object changed, and how the executor verified the result. A transcript of tool calls is not enough. Agent narration can be wrong, and raw HTTP bodies can be too sensitive or too large to review.

Give every operation and step stable identifiers. Record the plan hash, exact tenant, normalized subject, resolved remote IDs, approval decision, request fingerprint, response status, verification read, and compensation state. Store redacted response excerpts only when they help explain the result. Hashing an entire response can support later comparison without copying personal data into the journal.

Separate claims from observations. requested_role: member is a claim about intent. response_status: 200 is a transport observation. observed_role: member is a verified remote state. Putting all three into one boolean called success throws away the evidence needed during an incident.

The command-line view should make partial completion obvious. For example:

$ provision status prov_2026_07_24_0187
STEP  ACTION                 RESULT            UNDO
1     invitation.create      accepted          unavailable
2     acceptance.wait        observed          n/a
3     role.set               changed           ready: guest
4     group.add engineering  added             ready
5     group.add on-call      denied            none
STATE partial_failure

That output tells an operator that the account exists, the role changed, one membership succeeded, and the final group did not. It does not collapse the run into "provisioning failed." The distinction guides both rollback and a decision to continue after fixing authorization.

Protect the journal from the same agent that performs actions. If an agent can rewrite its own evidence, the record has little value. Sallyport keeps agent sessions and individual calls as two journal views projected from one encrypted, hash-chained log, and sp audit verify checks that chain offline over ciphertext. That design does not replace vendor-side audit logs, but it gives the local operator an independent sequence of credentialed calls.

Correlate local step IDs with vendor request IDs when responses provide them. During a support case, the vendor ID can locate server-side traces while the local record explains intent and approval. Preserve timestamps, but order events by a monotonic local sequence because clocks and asynchronous vendor events can disagree.

Retention needs a deliberate policy. Provisioning evidence may contain email addresses, group names, and role history. Keep the minimum fields needed for accountability, encrypt them, restrict readers, and expire auxiliary response data sooner than the core action record when policy allows.

Approval belongs at consequence boundaries

Review retries as real calls
Repeated invitation and membership attempts remain visible as individual entries in the Activity journal.

Human approval works when the card describes one concrete consequence. "Allow provisioning" is too broad. "Invite [email protected] to acme-production without sending email" is reviewable. "Change usr_1938 from guest to member" and "Add usr_1938 to production-deployers" deserve separate decisions when their risk differs.

Show resolved identifiers and current state, not only the agent's wording. An approval card should display the tenant, canonical subject, action, before and after values, and whether the step has an automatic compensation. For a pending invitation, state whether mail will be sent. For group access, show the immutable group ID beside its display name.

Session authorization can cover repetitive low-risk calls, while sensitive credentials or actions can demand approval on every use. Sallyport's fixed decision ladder supports that split: the vault must be unlocked, a new agent process receives session authorization by default, and a per-key flag can require approval for every call. Map the credential holding privileged provisioning authority to the strict boundary rather than asking the model to enforce its own restraint.

Approval does not excuse weak execution semantics. A person may approve the correct group and still face a duplicated POST after a timeout. The executor remains responsible for idempotency, verification, and compensation. Likewise, a perfect journal does not make an overbroad credential safe.

Avoid approval fatigue by eliminating prompts that carry no decision. Reads that expose limited metadata may fit the session boundary. Exact no-ops should be recorded without asking to "approve" a change that will not occur. Group several truly identical low-risk memberships only if the UI still shows every target and the rollback engine preserves separate receipts.

Revocation must stop future steps without pretending to reverse completed ones. When an operator revokes the agent session after step four, the executor should cancel queued calls, mark the operation interrupted, and offer the compensation plan. It should not silently use a different credential or start a new session.

A failed run should remain intelligible

Consider an agent asked to onboard a contractor with an ordinary account and two groups. Preflight finds no account. The invitation call times out after transmission, reconciliation finds one pending invitation, and the executor adopts its returned ID. The contractor accepts. The role update succeeds, the first group add succeeds, and the second group call returns 403 because the token lacks authority for that group.

This is a partial result, not an undifferentiated error. The operation now has an active member account and one direct group membership. The agent should stop, surface the exact denied step, and present two valid choices: keep the confirmed state while an operator grants authority for the remaining group, or compensate the first membership and restore any earlier role before addressing the account.

It should not delete the user. Deletion may remove data, invalidate an identity the contractor has already accepted, or collide with downstream provisioning. It should not retry the 403. It should not claim rollback is available for the delivered invitation email. It should not add a broader group as a substitute for the denied one.

The journal makes a later continuation safe. A new executor loads the immutable plan and receipts, reads current remote state, and checks that the account, role, and first group still match the observations. If they do, it can request approval for only the remaining membership. If a manager changed the role in the meantime, the plan is stale and needs a new decision.

This pattern scales beyond onboarding. Offboarding should separate session revocation, group removal, role change, account suspension, and deletion because their urgency and reversibility differ. License assignment should remain separate from group membership when the vendor exposes it separately. The rule is consistent: preserve the remote system's meaningful state boundaries in the agent's plan and evidence.

The extra calls are deliberate friction. They create places to verify identity, constrain authority, stop on divergence, and undo only what this operation changed. An autonomous agent earns more freedom when its work can be inspected at those boundaries. If a provisioning API forces several consequences into one irreversible call, classify that call honestly and put a person in front of it.

FAQ

Should an AI agent create a SaaS user and assign access in one call?

Usually not. Separate the invitation or account creation, role change, and each group membership so the executor can verify and undo them independently. Use a combined vendor endpoint only when its coupled effects are understood and approved as one irreversible unit.

What should happen when an invitation API times out?

Do not repeat the POST immediately. Search for the exact invitation by tenant and canonical subject, adopt a matching result if the evidence is unambiguous, and retry only after proving the first request made no change.

Is deleting the user a safe rollback for failed provisioning?

Deletion is rarely a safe default because it can remove data and interfere with an identity that already accepted an invitation. Compensate only the confirmed changes made by the run, such as direct group edges and the previous role.

How should an agent handle a group membership that already exists?

Record the step as already_present and do not add an undo action. Removing that membership during rollback would erase access that existed before the agent's operation.

When is a provisioning action truly reversible?

It is reversible when the system exposes a compensation that restores the observed prior authorization state and the executor has enough identifiers to apply it safely. Email delivery, billing effects, and downstream propagation may remain even after that compensation.

What belongs in a provisioning audit entry?

Record the operation and step IDs, tenant, remote object IDs, before and requested values, approval, response status, verification result, and compensation state. Keep credentials and sensitive redemption links out of the entry.

Can an agent retry PUT and DELETE requests automatically?

HTTP defines PUT and DELETE as idempotent by intended effect, but the executor still needs to respect vendor semantics and concurrent changes. Use version conditions where available and verify state after the call.

Should group additions run in parallel?

Sequential writes are safer for privileged provisioning. They preserve order, produce readable evidence, simplify rate-limit handling, and leave a precise compensation receipt after each confirmed membership.

How should approval cards describe SaaS provisioning?

Show one concrete consequence with the tenant, canonical subject, immutable target ID, before and after state, delivery side effects, and available compensation. A broad prompt such as "allow onboarding" hides too much.

What should an agent do after one provisioning step gets a 403?

Stop and report the exact partial state. Do not retry a permission error or substitute broader access; offer continuation after authorization is corrected or a compensation plan for the steps that already changed state.

Sallyport

Sallyport runs API calls and SSH commands for your AI agent. The keys stay in a local vault on your Mac; you approve each run and every action lands in a sealed journal.

© 2026 Sallyport · Open source under Apache-2.0 · Oleg Sotnikov