Automated deprovisioning workflows
Cleanup when users leave or become inactive
When users leave (B2B employee termination, account abandonment), you need to deprovision: revoke access, remove from groups, clean up resources.
Triggers
Trigger A: SCIM signal
Customer's IdP (Okta, Entra) calls your SCIM endpoint:
PATCH /scim/v2/Users/abc-123
{ "Operations": [{ "op": "replace", "path": "active", "value": false }] }Your handler deactivates:
await kratos.adminPatch(identityId, [
{ op: "replace", path: "/state", value: "inactive" }
]);
await revokeAllSessions(identityId);
await sendDeprovisionedEmail(identity);See SCIM endpoint.
Trigger B: Manual admin action
In Athena:
<Button onClick={() => deprovision(identityId)}>Deactivate</Button>Server action runs same flow.
Trigger C: Inactivity policy
Cron: users inactive 12 months → deactivate.
SELECT id FROM identities
WHERE state = 'active'
AND (
(SELECT MAX(created_at) FROM security_audit WHERE identity_id = identities.id AND event_type = 'login')
< NOW() - INTERVAL '12 months'
);Email warning first ("Your account will be deactivated in 7 days due to inactivity"). If no response, deactivate.
Trigger D: Subscription cancelled
If subscription ends and grace expires:
// In Stripe webhook
if (event.type === "customer.subscription.deleted") {
const customer = event.data.object.customer;
await markCustomerInactive(customer); // grace 30d
}After grace: deactivate.
What deprovisioning does
For each affected user:
- Set state to inactive.
- Revoke all sessions (so they're logged out everywhere).
- Revoke active OAuth2 tokens (refresh, access).
- Remove from teams / orgs (if applicable).
- Disable API keys / PATs.
- Notify the user.
Optional:
- Schedule data deletion (per retention).
- Notify their managers / collaborators.
- Cancel subscriptions.
async function fullDeprovision(identityId) {
await db.transaction(async (tx) => {
await tx`UPDATE identities SET state = 'inactive' WHERE id = ${identityId}`;
await tx`UPDATE kratos.sessions SET revoked_at = NOW() WHERE identity_id = ${identityId}`;
await tx`DELETE FROM personal_access_tokens WHERE identity_id = ${identityId}`;
await tx`DELETE FROM memberships WHERE identity_id = ${identityId}`;
});
// Stripe
if (user.stripe_customer_id) {
const subs = await stripe.subscriptions.list({ customer: user.stripe_customer_id });
for (const sub of subs.data) await stripe.subscriptions.cancel(sub.id);
}
// Audit
await audit.log({ event: "user_deprovisioned", target: identityId, actor: "system" });
// Notify
await email.send(user.email, "Your account has been deactivated", ...);
}What stays
After deprovisioning, retain:
- Identity row (state=inactive, data preserved).
- Audit log entries.
- Their owned resources (for billing / accountability).
Their data isn't deleted by deprovisioning. That's a separate user-action ("delete my account").
Reactivation
If user comes back (false termination, returning customer):
async function reactivate(identityId) {
await kratos.adminPatch(identityId, [
{ op: "replace", path: "/state", value: "active" }
]);
await sendEmail(user.email, "Welcome back", ...);
}State changes back. Data was preserved.
Resource ownership transfer
If user owned shared resources (documents, projects), what happens when they leave?
Options:
- Keep: resources stay owned by inactive user. Others can still access if invited.
- Transfer: bulk-assign to a designated owner (manager, team lead).
- Delete: aggressive, usually wrong.
For most B2B: transfer to manager. Document the policy.
async function transferOwnership(fromUserId, toUserId) {
await db`UPDATE projects SET owner_id = ${toUserId} WHERE owner_id = ${fromUserId}`;
await db`UPDATE documents SET owner_id = ${toUserId} WHERE owner_id = ${fromUserId}`;
// notify new owner
}Notification timing
When user is deprovisioned:
- Immediate notice ("Your account has been deactivated as of [time]").
- 30 days before scheduled delete (if applicable).
Don't bury the lede. Be transparent.
Audit
Every deprovisioning event:
INSERT INTO security_audit (event_type, target_id, actor_id, metadata)
VALUES (
'user_deprovisioned',
$user_id,
$actor_or_system_id,
'{"reason": "$reason"}'
);Reason categories:
inactivity_12moscim_active_falseadmin_manualsubscription_cancelleduser_requested
Reportable for compliance.
Bulk deprovisioning
End of a quarter, deprovision lots:
const inactive = await findInactiveUsers(); // some criteria
await processBulk(inactive, deprovision, 5); // 5 concurrentSend summary email to admins:
This quarter:
- Deprovisioned 234 users (inactivity).
- 12 users requested reactivation.
- 5 users had ongoing subscriptions cancelled.Reversibility
Always reversible for 30+ days. Deactivation is soft. Data preserved.
Hard delete is a separate, slower, explicit step.