Detecting Orphaned Tenant Admin Accounts
An orphaned admin is an account with full authority inside a tenant and no living human accountable for it, and this page shows how to find them from data you already collect. It is a focused part of Tenant Access Reviews & Certification: the detection signals, how to rank them, and how to act without locking a customer out of their own workspace.
Problem Framing
Orphaned admins accumulate through entirely ordinary events. The person who signed up leaves and nobody transfers ownership. An implementation consultant is made admin during onboarding and their engagement ends. A shared mailbox — it@customer.example — becomes the account of record and later routes to a distribution list nobody reads. A contractor's identity provider account is deleted while their locally-created account in your product survives, because deletion in the directory was never propagated.
Each of these is a standing, highly privileged credential that no review will catch, because a review asks a reviewer to certify access, and an orphaned admin is often the reviewer. Sole-admin tenants are the acute case: the certification control silently degrades into self-attestation by an account that may no longer belong to anyone.
Detection has to come from behaviour and from external signals rather than from an attestation. The useful ones are cheap and already in your systems: last successful authentication, last privileged action, mail deliverability, whether the identity provider still knows the account, and whether the address looks like a person at all.
Step-by-Step Guide
1. Materialise last-activity per admin
CREATE MATERIALIZED VIEW admin_activity AS
SELECT g.tenant_id,
g.principal_id,
u.user_name,
max(s.authenticated_at) AS last_login,
max(a.at) FILTER (WHERE a.action LIKE 'admin.%') AS last_admin_action,
count(*) FILTER (WHERE a.action LIKE 'admin.%') AS admin_action_count
FROM tenant_role_grant g
JOIN tenant_user u ON u.id = g.principal_id AND u.tenant_id = g.tenant_id
LEFT JOIN auth_session s ON s.principal_id = g.principal_id AND s.tenant_id = g.tenant_id
LEFT JOIN audit_event a ON a.subject_id = g.principal_id AND a.tenant_id = g.tenant_id
WHERE g.role_key = 'admin' AND g.revoked_at IS NULL
GROUP BY g.tenant_id, g.principal_id, u.user_name;
CREATE UNIQUE INDEX ON admin_activity (tenant_id, principal_id);
Refreshing this concurrently on a nightly schedule keeps the detection query cheap enough to run against every tenant rather than against a sampled subset.
2. Add the external signals
// signals.ts
const SHARED_MAILBOX = /^(it|admin|info|support|billing|noreply|team|ops|sysadmin)@/i;
export async function externalSignals(tenantId: string, u: AdminRow) {
const [bounced, inDirectory] = await Promise.all([
mail.hasHardBounce(u.userName, { since: days(90) }),
scim.wasSeenInLastSync(tenantId, u.principalId),
]);
return {
bounced,
missingFromDirectory: scimEnabled(tenantId) ? !inDirectory : false,
sharedMailbox: SHARED_MAILBOX.test(u.userName),
};
}
The directory signal only applies when the tenant actually uses SCIM; treating "not in the directory" as suspicious for a tenant with no directory would flag every account they have.
3. Score, with weights that reflect real risk
// score.ts
export function orphanScore(a: AdminRow, s: Signals): number {
let score = 0;
if (!a.lastLogin) score += 35;
else if (olderThanDays(a.lastLogin, 180)) score += 25;
else if (olderThanDays(a.lastLogin, 90)) score += 10;
if (a.adminActionCount === 0) score += 15;
if (s.bounced) score += 30;
if (s.missingFromDirectory) score += 30;
if (s.sharedMailbox) score += 10;
if (a.isSoleAdmin) score += 10; // raises consequence, not likelihood
return Math.min(score, 100);
}
A hard bounce and a missing directory entry are weighted highest because they are the two signals that are close to proof: mail that cannot be delivered and an identity the customer's own directory no longer recognises.
4. Detect sole-admin tenants separately
-- Tenants where certification cannot work, because there is nobody else to certify.
SELECT g.tenant_id, count(*) AS admin_count, min(u.user_name) AS only_admin
FROM tenant_role_grant g
JOIN tenant_user u ON u.id = g.principal_id AND u.tenant_id = g.tenant_id
WHERE g.role_key = 'admin' AND g.revoked_at IS NULL AND u.active
GROUP BY g.tenant_id
HAVING count(*) = 1;
This list is worth acting on regardless of orphan score. A sole admin is a business-continuity risk for the customer and a control gap for you, and the remedy — prompting the tenant to nominate a second admin — is a product improvement rather than a security intervention.
5. Escalate on a schedule the customer can act on
// escalate.ts
export async function escalate(tenantId: string, admin: AdminRow, score: number) {
if (score < 40) return { action: 'monitor' };
if (score < 70) {
await notifyOtherAdmins(tenantId, 'confirm_admin_ownership', { admin: admin.userName });
return { action: 'notified' };
}
await notifyOtherAdmins(tenantId, 'admin_will_be_downgraded', {
admin: admin.userName, deadline: addDays(new Date(), 14),
});
await schedule('downgrade_admin', addDays(new Date(), 14), { tenantId, principalId: admin.principalId });
return { action: 'scheduled_downgrade' };
}
Downgrade to a read-only role rather than deleting the account. Deletion destroys audit attribution for everything that account ever did, and it is the step that turns a helpful intervention into a support escalation.
Verification
-- 1. High-scoring admins with no escalation recorded.
SELECT a.tenant_id, a.user_name, a.orphan_score
FROM admin_orphan_score a
LEFT JOIN orphan_escalation e USING (tenant_id, principal_id)
WHERE a.orphan_score >= 70 AND e.id IS NULL;
-- expected: no rows
-- 2. Sole-admin tenants that have not been prompted in 90 days.
SELECT tenant_id FROM sole_admin_tenant
WHERE tenant_id NOT IN (
SELECT tenant_id FROM tenant_nudge
WHERE kind = 'add_second_admin' AND sent_at > now() - interval '90 days');
-- 3. Downgrades that happened without the full notice period.
SELECT * FROM orphan_escalation
WHERE downgraded_at IS NOT NULL AND downgraded_at < notified_at + interval '14 days';
-- expected: no rows
The third query is the important one to keep green: a downgrade that fires early is a customer locked out of their own admin console with no warning, which is the failure mode that gets this whole control switched off.
Failure Modes & Gotchas
-
Symptom: a legitimate break-glass admin is downgraded. Cause: inactivity alone drives the decision. Fix: require corroboration — a bounce or a missing directory entry — before the high band, and let tenants mark an account as intentionally dormant.
-
Symptom: every account at a customer is flagged after they adopt SSO. Cause: the directory signal is applied to a tenant whose SCIM sync has never completed. Fix: gate the signal on a successful full sync within the retention window, not merely on SCIM being configured.
-
Symptom: audit history becomes unattributable. Cause: orphaned accounts are deleted rather than downgraded. Fix: downgrade the role and keep the principal; deletion should only ever follow tenant offboarding.
-
Symptom: notifications reach nobody. Cause: they are sent to the orphaned admin, who is the one person guaranteed not to read them. Fix: notify every other admin, and fall back to the billing contact when there is no second admin.
FAQ
How long should an admin be inactive before it counts as a signal? Ninety days is a reasonable first threshold and a hundred and eighty a strong one, but inactivity alone should never reach the top band. Many legitimate admins log in twice a year, at renewal and during an incident. Use inactivity to rank and corroborating signals to decide.
Is it acceptable to revoke access at a customer without their approval? Downgrading an unreachable, unowned admin after two notifications to the tenant's other administrators is defensible and is usually welcomed — it is the same posture as forcing a password reset after a breach. What is not defensible is doing it silently, doing it without an appeal path, or deleting the account. Notify, give a deadline, make it reversible in one click.
What if the only admin is orphaned? That is a support case, not an automated one. Downgrading the sole admin locks the customer out entirely, so route it to a human process: contact the billing owner and the technical contact on record, verify ownership through the contract relationship, and appoint a new admin. The real fix is upstream — prompt every sole-admin tenant to add a second administrator long before this situation arises.
Should service accounts be scored the same way? No, because almost every signal means something different for them. A machine identity that has never logged in interactively is entirely normal, mail deliverability is irrelevant, and absence from a human directory is expected. What matters instead is whether the credential has been used at all in the retention window, whether it still has a named human owner, and whether its scopes still match what the integration does. Run them as a separate campaign with their own signals rather than forcing them through a model built for people.
How does this interact with customers who never respond to notifications? It is the case the whole escalation exists for, and the answer is to keep the outcome reversible rather than to keep escalating. After the notice period, downgrade rather than delete, leave a clear message in the product explaining what happened and how to undo it, and make the undo a single action for any remaining admin. A customer who discovers the change three weeks later should be able to restore it themselves without a support ticket, which is what makes it defensible to have acted without their reply.