Skip to content

Diagnostic Slack

not_an_admin: workspace admin is not the org role

The token is a user token. The scopes are on it — you checked, twice. And admin.teams.list answers {"ok": false, "error": "not_an_admin"}, which is a strange thing to be told when you are, demonstrably, an admin: it says so on your own profile.

It does say so. It says so in the field that describes your role in one workspace. The methods you are calling are organization methods, they check an organization role, and that role lives in a different object in the same response. Both fields are called is_admin, one is true, and the one the method reads is the other one.

Read-only token Python and Node.js Tests included
A computer screen with a bunch of code
Photo by Jiri Navratil on Unsplash
The short answer

admin.* scopes can be granted to any user token regardless of who holds it. The methods do not rely on the grant: they check the caller's actual role when the call arrives, and they want an organization Admin or Owner. So a user token with a perfect scope list and an ordinary account behind it produces not_an_admin forever, and no reinstall changes that unless a different person performs it.

The confusion is a field name collision, and it is worth being precise about. users.info returns is_admin, is_owner and is_primary_owner at the root of the user object, and those describe the role in the workspace the call is scoped to. On Enterprise Grid the same user object carries an enterprise_user object, and that is where the org role lives: enterprise_user.is_admin and enterprise_user.is_owner are documented as indicating whether the user is an Admin or an Owner of the Enterprise organization. A workspace admin in forty workspaces is still not an org admin.

The script below reads the role rather than probing methods. auth.test gives you the caller's user id, users.info gives you both role objects, and the answer is a sentence about a person: this account holds the workspace admin role and not the org role, so every admin.* call it makes will be refused. It will also, given users.list, name the accounts that do hold the org role, because the useful output of this check is not "you cannot" but "ask one of these three people".

And there is a second finding hiding in the same read: a role can be lost after the install. Nothing in your configuration changes when that happens.

The problem in plain words

The install worked, which is what makes this so slow to diagnose. OAuth completed, the consent screen listed the admin scopes and the person clicked through them, and the resulting token carries exactly the grant you asked for. Every check you can perform on the credential itself passes. The class is right, the scopes are right, and the call is still refused, so the natural next move is to look at the scopes again, which is the one place the answer is not.

Then the profile confirms your innocence. The developer doing the debugging opens Slack, sees the admin badge on their own account, and reasonably concludes that the error is wrong or that some other scope is missing. On Grid, a workspace admin genuinely is an admin: they can manage members and channels in that workspace, and the is_admin field on their user object is true. It is simply a different role from the one that governs organization-level methods, and nothing in the error string distinguishes them.

The second half of the problem is time. Role is not a property of the installation, it is a property of a person, and people move. The person who installed the app was an org admin in March and is a team lead in a different department by October, and on the day their role changes an integration that has run without incident for seven months starts returning not_an_admin with no deploy, no configuration change and nothing in any changelog to point at. Departures do the same thing more permanently.

There is a structural version of the same trouble. Installing with a named individual's account ties an organization-wide capability to one employee's continued role and continued employment, which is a fragile arrangement even when nobody's job changes: it makes the integration invisible to the organization's own access review, and it means the offboarding checklist that disables the account also breaks a production system nobody associated with that person.

Scopes askedfor properlyunder User TokenScopesA developerinstallstheir own account,clicked throughThe tokencarries theevery check on itpassesThe methodreads a rolenot the token, thepersonnot_an_adminand no reinstallchanges it
Four correct steps and then a lookup that has nothing to do with the credential. The badge on the installer's own profile says admin throughout, because it describes a different role.

Why it happens

The finding is about a person, so the check reads a person. It would be possible to establish this by calling an admin method and catching not_an_admin, and that is a fine confirmation, but it produces one bit of information and no next step. Reading users.info for the installing account produces the whole sentence: this is the account, here is its workspace role, here is its org role, and here is the specific field the method is checking.

Two is_admin fields is the entire trap, so the script never conflates them. roles() returns the workspace role and the org role separately and carries a third value, org_role_known, which is false when the user object has no enterprise_user at all. That absence has two quite different causes — the workspace is not part of an Enterprise organization, or the token cannot see the org identity — and neither of them means "not an admin". Reporting unknown is the honest answer and the script says so rather than rounding down.

An unknown org role is never reported as a failure. This is the conservative rule that keeps the check usable in a multi-tenant audit: if the org role cannot be read, the verdict is that it cannot be read. A checker that treats missing evidence as a negative finding will tell every non-Grid customer that their installer lacks a role that does not exist on their plan, which is a different note entirely.

The candidate list is the actionable half. Knowing the installer lacks the role does not tell anybody what to do next; naming the accounts that hold it does. candidates() walks users.list and returns the accounts whose enterprise_user shows the org role, along with a coverage number, because a candidate list built from a page of members you could not fully read is a shorter list than the truth and should say so.

Role drift is a first-class finding rather than an afterthought. If you recorded the installer's role at install time, role_drift() compares it to now and reports lost, which is the only diagnosis that explains an integration breaking on a Tuesday with no deploy. If you did not record it, that is itself the recommendation: store the role alongside the installation, and assert it at startup.

Nothing here writes and nothing escalates itself. The repair is a human one — an org Admin or Owner re-runs the install, ideally from a dedicated service account with the role rather than from an individual — and the script prints it. There is no API that grants a role, and any script that appeared to offer one would be doing something else.

The fix, as a flow

The red arrow is the last one, which is what makes this refusal so durable. Read the chain from the left and every box is a step done properly: the scopes were requested in the right place, the install was authorised, the token came back carrying the grant it was supposed to carry. Nothing about the credential is wrong, and every check you can run on the credential passes. The failure happens at the far end, where the method stops looking at the token and looks up a role, and a role is a property of a person. The fix branch sorts accounts, and the row that matters most is the fourth: a workspace admin with no enterprise_user object is not a person without the role, it is a question that could not be answered, and reporting it as a failure faults every customer on a smaller plan.

The account behind the tokentwo roles, one field nameOrg admin or ownerthe methods will accept thisOrg role read, both falsethe finding, stated plainlyHeld the role in Marchand does not hold it nowNo enterprise_user objectunknown, and never a failureSomeone else holds itname them, and ask them
One users.info answers two questions with the same field name. The workspace role is the one on the profile badge; the org role is the one the methods read, and only the second decides anything.

How to fix it

Identify the account the token actually belongs to

auth.test returns user_id for a user token. Use that rather than a user id from your configuration, because the account that performed the install and the account you think performed it are not reliably the same person.

Read both role objects out of one users.info response

roles pulls the workspace role from the root of the user object and the org role from enterprise_user, and sets org_role_known to false when that object is absent. One call, two answers, kept apart.

Let the verdict say unknown when it is unknown

satisfies returns yes, no or unknown. It answers no only when the org role was readable and both flags were false. A workspace admin with no enterprise_user object is unknown, and the reason is printed.

Name the people who can fix it

Pass --team-id and candidates walks users.list for accounts whose enterprise_user shows the org role, skipping bots and deactivated accounts. It reports coverage alongside the names, because a list built from partial data is a partial list.

Compare against the role you recorded at install time

role_drift takes the role you stored with the installation and reports lost, gained, stable or unknown. lost is the only finding that explains a working integration failing with no change on your side.

Reinstall as the right account, and assert it at startup

Have an org Admin or Owner perform the install, preferably a dedicated service account holding the role rather than an individual's account. Then assert the org role on boot and fail loudly, so the next role change is a startup error rather than a silent afternoon.

How to check it worked

Reinstall from an account that holds the org role and re-run. The role line should read yes, and the drift line should read stable on every run after that.

python3 slack_admin_role.py --team-id T00000001 --recorded-role org-admin
# caller     U04XXXXXX        auth.test returned a user_id and no bot_id
# role       workspace        admin=true owner=false primary_owner=false
# role       organization     admin=false owner=false  (from enterprise_user)
# admin.*    no               the org role was readable and neither flag is set;
#                             workspace admin is a different role from org admin
# drift      lost             recorded org-admin at install time, reads member now
# candidate  U01AAAAAA        org owner
# candidate  U02BBBBBB        org admin
# coverage   partial          312 of 400 members carried an enterprise_user object
# verdict    1 finding(s)     the installing account cannot satisfy admin.* methods
#   repair: have one of the accounts above re-run the installation
#   repair: prefer a service account holding the org role over an individual's
#   repair: assert enterprise_user.is_admin at startup and fail loudly

The full code

The reading is one users.info call and everything after it is pure. roles is the function that refuses to collapse two fields with the same name into one answer: the workspace role and the org role come back separately, with a flag saying whether the second was readable at all. satisfies is deliberately three-valued, because “no enterprise_user object” is not evidence of anything and must not be reported as a failing role. candidates turns the finding into a next step by naming the accounts that hold the role, and role_drift catches the version of this that arrives seven months after a successful install.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_admin_role.py
"""Decide whether the human behind your user token can satisfy admin.* methods.

Read only. The token class question is a different script; this one assumes
the class is right and asks about the person, because admin.* scopes can be
granted to any user token while the methods check the caller's actual role at
call time.

The trap this is built around is a field name collision. users.info returns
is_admin, is_owner and is_primary_owner at the root of the user object, which
describe the role in the workspace the call is scoped to. On Enterprise Grid
the same object carries enterprise_user, and that is where the organization
role lives. Both fields are called is_admin. The methods read the second one.

The rule that keeps this honest in a multi-tenant audit: an org role that
could not be read is reported as unknown, never as absent.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_admin_role")

API = "https://slack.com/api/"

# This note explains exactly one of the refusals the admin surface produces.
# The others have their own, and pointing at them beats a shallow paragraph.
EXPLAINED_HERE = "not_an_admin"
OWNED_ELSEWHERE = {
    "not_allowed_token_type": "/slack/admin-method-needs-user-token/",
    "missing_scope": "/slack/missing-scope-on-read/",
    "feature_not_enabled": "/slack/feature-not-enabled/",
    "app_access_restricted": "/slack/app-access-restricted/",
    "ekm_access_denied": "/slack/ekm-access-denied/",
}


def roles(user):
    """Read both role objects out of one user object. Pure.

    Returns a dict with the workspace role, the organization role and
    org_role_known. The last one matters more than it looks: an absent
    enterprise_user object means either that this workspace is not part of an
    Enterprise organization or that the token cannot see the org identity, and
    neither of those is evidence that the person lacks a role.
    """
    doc = user or {}
    enterprise = doc.get("enterprise_user")
    known = isinstance(enterprise, dict)
    enterprise = enterprise if known else {}
    return {
        "user_id": doc.get("id"),
        "workspace_admin": bool(doc.get("is_admin")),
        "workspace_owner": bool(doc.get("is_owner")),
        "primary_owner": bool(doc.get("is_primary_owner")),
        "org_admin": bool(enterprise.get("is_admin")),
        "org_owner": bool(enterprise.get("is_owner")),
        "org_role_known": known,
        "is_bot": bool(doc.get("is_bot")),
        "deleted": bool(doc.get("deleted")),
    }


def satisfies(role):
    """Can this person satisfy an admin.* method? Pure.

    Three valued on purpose. "no" is returned only when the org role was
    readable and neither flag was set. Everything else that is not a yes is
    unknown, because a checker that reads missing evidence as a failure tells
    every non-Grid customer that their installer lacks a role their plan does
    not have.
    """
    doc = role or {}
    if doc.get("deleted"):
        return ("no", "the account is deactivated, so it holds no role at all")
    if doc.get("is_bot"):
        return ("no", "this is a bot account; admin.* methods want a human org "
                      "Admin or Owner")
    if doc.get("org_admin") or doc.get("org_owner"):
        return ("yes", "enterprise_user shows the organization role the methods check")
    if not doc.get("org_role_known"):
        if doc.get("workspace_admin") or doc.get("workspace_owner"):
            return ("unknown", "this account holds a workspace role, but the user "
                               "object carries no enterprise_user, so the "
                               "organization role could not be read")
        return ("unknown", "no enterprise_user object, so the organization role "
                           "could not be read either way")
    return ("no", "the org role was readable and neither flag is set; workspace "
                  "admin is a different role from org admin")


def role_drift(recorded, role):
    """Compare the role you stored at install time with the role now. Pure.

    Returns (state, detail). "lost" is the finding that explains an integration
    breaking with no deploy, no configuration change and nothing to bisect.
    """
    now = "org-owner" if (role or {}).get("org_owner") else (
        "org-admin" if (role or {}).get("org_admin") else "member")
    if not (role or {}).get("org_role_known"):
        now = "unknown"
    was = str(recorded or "").strip().lower()
    if not was:
        return ("unrecorded", "no role was stored with the installation, so drift "
                              "cannot be read; store it and this becomes readable")
    if now == "unknown":
        return ("unknown", "recorded %s at install time, and the org role cannot be "
                           "read now" % was)
    privileged = ("org-admin", "org-owner")
    if was in privileged and now not in privileged:
        return ("lost", "recorded %s at install time, reads %s now" % (was, now))
    if was not in privileged and now in privileged:
        return ("gained", "recorded %s at install time, reads %s now" % (was, now))
    return ("stable", "recorded %s, reads %s" % (was, now))


def candidates(members, limit=10):
    """Who in this workspace holds the organization role? Pure.

    Returns (people, coverage). Coverage is the honest half: a candidate list
    assembled from member objects that carried no enterprise_user is shorter
    than the truth, and a short list of people to ask is worse than a short
    list that says it is short.
    """
    people, seen, readable = [], 0, 0
    for member in members or []:
        role = roles(member)
        if role["is_bot"] or role["deleted"]:
            continue
        seen += 1
        if role["org_role_known"]:
            readable += 1
        if role["org_owner"]:
            people.append((role["user_id"], "org owner"))
        elif role["org_admin"]:
            people.append((role["user_id"], "org admin"))
    if not seen:
        coverage = ("none", "no readable member objects")
    elif readable == seen:
        coverage = ("full", "%d of %d members carried an enterprise_user object"
                            % (readable, seen))
    else:
        coverage = ("partial", "%d of %d members carried an enterprise_user object"
                               % (readable, seen))
    return (people[:limit], coverage)


def explains(error):
    """Is this refusal the one this note is about? Pure.

    Returns (state, pointer). Keeping the map here rather than in prose means
    a script that catches an unexpected error still hands the reader somewhere
    useful rather than silently mis-diagnosing it as a role problem.
    """
    name = str(error or "").strip()
    if name == EXPLAINED_HERE:
        return ("this-note", "a role problem: the class is right and the person "
                             "does not hold the organization role")
    if name in OWNED_ELSEWHERE:
        return ("elsewhere", OWNED_ELSEWHERE[name])
    if not name:
        return ("none", "no error was passed")
    return ("unknown", "not a refusal this note recognises; check the method "
                       "reference for its error list")


def verdict(state, drift):
    """One line for the whole check. Pure."""
    findings = (1 if state == "no" else 0) + (1 if drift == "lost" else 0)
    if state == "unknown":
        return ("inconclusive", "the organization role could not be read, so this "
                                "account was neither cleared nor faulted")
    if not findings:
        return ("clean", "the installing account holds the organization role")
    return ("%d finding(s)" % findings,
            "the installing account cannot satisfy admin.* methods")


def get(session, method, params=None):
    """One GET. Returns the parsed body."""
    r = session.get(API + method, params=params or {}, timeout=30)
    try:
        return r.json()
    except ValueError:
        return {"ok": False, "error": "unparseable_body"}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--token-env", default="SLACK_USER_TOKEN",
                    help="environment variable holding the user token your admin "
                         "calls use")
    ap.add_argument("--team-id", default="",
                    help="workspace id, so the candidate scan can run")
    ap.add_argument("--recorded-role", default="",
                    help="the role you stored for the installer at install time: "
                         "org-owner, org-admin or member")
    ap.add_argument("--error", default="",
                    help="a refusal you captured, to check this note explains it")
    args = ap.parse_args()

    token = os.environ.get(args.token_env)
    if not token:
        log.error("token      missing        set %s", args.token_env)
        return 2
    session = requests.Session()
    session.headers.update({"Authorization": "Bearer " + token})

    if args.error:
        state, detail = explains(args.error)
        (log.info if state == "this-note" else log.warning)(
            "error      %-14s %s", state, detail)

    auth = get(session, "auth.test")
    if auth.get("ok") is not True:
        log.error("caller     unreadable     auth.test: %s", auth.get("error"))
        return 2
    user_id = auth.get("user_id")
    if auth.get("bot_id"):
        log.warning("caller     %-16s this is a bot token; admin.* wants a user "
                    "token held by an org Admin or Owner", user_id)
    log.info("caller     %-16s auth.test returned the account this token belongs to",
             user_id)

    body = get(session, "users.info", {"user": user_id})
    if body.get("ok") is not True:
        log.error("role       unreadable     users.info: %s", body.get("error"))
        return 2
    role = roles(body.get("user"))
    log.info("role       workspace        admin=%s owner=%s primary_owner=%s",
             str(role["workspace_admin"]).lower(), str(role["workspace_owner"]).lower(),
             str(role["primary_owner"]).lower())
    if role["org_role_known"]:
        log.info("role       organization     admin=%s owner=%s  (from enterprise_user)",
                 str(role["org_admin"]).lower(), str(role["org_owner"]).lower())
    else:
        log.warning("role       organization     not readable; the user object carries "
                    "no enterprise_user")

    state, why = satisfies(role)
    (log.info if state == "yes" else log.warning)("admin.*    %-16s %s", state, why)

    drift, detail = role_drift(args.recorded_role, role)
    (log.info if drift in ("stable", "gained") else log.warning)(
        "drift      %-16s %s", drift, detail)

    if args.team_id and state != "yes":
        page = get(session, "users.list", {"team_id": args.team_id, "limit": 200})
        people, coverage = candidates(page.get("members") or [])
        for user, label in people:
            log.info("candidate  %-16s %s", user, label)
        if not people:
            log.warning("candidate  none found       no member on this page showed the "
                        "organization role")
        log.info("coverage   %-16s %s", coverage[0], coverage[1])

    final, summary = verdict(state, drift)
    if final == "clean":
        log.info("verdict    clean            %s", summary)
        return 0
    log.warning("verdict    %-16s %s", final, summary)
    log.warning("  repair: have an org Admin or Owner re-run the installation so the "
                "user token belongs to a privileged account")
    log.warning("  repair: prefer a dedicated service account holding the org role "
                "over an individual's account, which changes when they do")
    log.warning("  repair: assert the org role at startup and fail loudly, so the next "
                "role change is a boot error rather than a silent afternoon")
    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-admin-role.mjs
/**
 * Decide whether the human behind your user token can satisfy admin.* methods.
 *
 * Read only. The token class question is a different module; this one assumes
 * the class is right and asks about the person, because admin.* scopes can be
 * granted to any user token while the methods check the caller's actual role
 * at call time.
 *
 * The trap this is built around is a field name collision: users.info returns
 * is_admin at the root of the user object for the workspace role, and again
 * inside enterprise_user for the organization role. The methods read the
 * second one. An org role that could not be read is reported as unknown,
 * never as absent.
 */

const API = 'https://slack.com/api/';

export const EXPLAINED_HERE = 'not_an_admin';
export const OWNED_ELSEWHERE = {
  not_allowed_token_type: '/slack/admin-method-needs-user-token/',
  missing_scope: '/slack/missing-scope-on-read/',
  feature_not_enabled: '/slack/feature-not-enabled/',
  app_access_restricted: '/slack/app-access-restricted/',
  ekm_access_denied: '/slack/ekm-access-denied/',
};

export function roles(user) {
  const doc = user ?? {};
  const known = doc.enterprise_user !== null && typeof doc.enterprise_user === 'object';
  const enterprise = known ? doc.enterprise_user : {};
  return {
    user_id: doc.id,
    workspace_admin: Boolean(doc.is_admin),
    workspace_owner: Boolean(doc.is_owner),
    primary_owner: Boolean(doc.is_primary_owner),
    org_admin: Boolean(enterprise.is_admin),
    org_owner: Boolean(enterprise.is_owner),
    org_role_known: known,
    is_bot: Boolean(doc.is_bot),
    deleted: Boolean(doc.deleted),
  };
}

// Three valued on purpose: "no" only when the org role was readable and
// neither flag was set.
export function satisfies(role) {
  const doc = role ?? {};
  if (doc.deleted) return ['no', 'the account is deactivated, so it holds no role at all'];
  if (doc.is_bot) {
    return ['no', 'this is a bot account; admin.* methods want a human org Admin or Owner'];
  }
  if (doc.org_admin || doc.org_owner) {
    return ['yes', 'enterprise_user shows the organization role the methods check'];
  }
  if (!doc.org_role_known) {
    if (doc.workspace_admin || doc.workspace_owner) {
      return ['unknown', 'this account holds a workspace role, but the user object '
        + 'carries no enterprise_user, so the organization role could not be read'];
    }
    return ['unknown', 'no enterprise_user object, so the organization role could not '
      + 'be read either way'];
  }
  return ['no', 'the org role was readable and neither flag is set; workspace admin is '
    + 'a different role from org admin'];
}

export function roleDrift(recorded, role) {
  const doc = role ?? {};
  let now = doc.org_owner ? 'org-owner' : (doc.org_admin ? 'org-admin' : 'member');
  if (!doc.org_role_known) now = 'unknown';
  const was = String(recorded ?? '').trim().toLowerCase();
  if (!was) {
    return ['unrecorded', 'no role was stored with the installation, so drift cannot '
      + 'be read; store it and this becomes readable'];
  }
  if (now === 'unknown') {
    return ['unknown', `recorded ${was} at install time, and the org role cannot be read now`];
  }
  const privileged = ['org-admin', 'org-owner'];
  if (privileged.includes(was) && !privileged.includes(now)) {
    return ['lost', `recorded ${was} at install time, reads ${now} now`];
  }
  if (!privileged.includes(was) && privileged.includes(now)) {
    return ['gained', `recorded ${was} at install time, reads ${now} now`];
  }
  return ['stable', `recorded ${was}, reads ${now}`];
}

export function candidates(members, limit = 10) {
  const people = [];
  let seen = 0;
  let readable = 0;
  for (const member of members ?? []) {
    const role = roles(member);
    if (role.is_bot || role.deleted) continue;
    seen += 1;
    if (role.org_role_known) readable += 1;
    if (role.org_owner) people.push([role.user_id, 'org owner']);
    else if (role.org_admin) people.push([role.user_id, 'org admin']);
  }
  let coverage;
  if (!seen) coverage = ['none', 'no readable member objects'];
  else if (readable === seen) {
    coverage = ['full', `${readable} of ${seen} members carried an enterprise_user object`];
  } else {
    coverage = ['partial', `${readable} of ${seen} members carried an enterprise_user object`];
  }
  return [people.slice(0, limit), coverage];
}

export function explains(error) {
  const name = String(error ?? '').trim();
  if (name === EXPLAINED_HERE) {
    return ['this-note', 'a role problem: the class is right and the person does not '
      + 'hold the organization role'];
  }
  if (name in OWNED_ELSEWHERE) return ['elsewhere', OWNED_ELSEWHERE[name]];
  if (!name) return ['none', 'no error was passed'];
  return ['unknown', 'not a refusal this note recognises; check the method reference '
    + 'for its error list'];
}

export function verdict(state, drift) {
  const findings = (state === 'no' ? 1 : 0) + (drift === 'lost' ? 1 : 0);
  if (state === 'unknown') {
    return ['inconclusive', 'the organization role could not be read, so this account '
      + 'was neither cleared nor faulted'];
  }
  if (!findings) return ['clean', 'the installing account holds the organization role'];
  return [`${findings} finding(s)`,
    'the installing account cannot satisfy admin.* methods'];
}

function arg(args, name, fallback = '') {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function read(token, method, params = {}) {
  const url = new URL(API + method);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  try {
    return await r.json();
  } catch {
    return { ok: false, error: 'unparseable_body' };
  }
}

async function main() {
  const args = process.argv.slice(2);
  const tokenEnv = arg(args, '--token-env', 'SLACK_USER_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`token      missing        set ${tokenEnv}`);
    process.exitCode = 2;
    return;
  }

  const captured = arg(args, '--error');
  if (captured) {
    const [state, detail] = explains(captured);
    const line = `error      ${state.padEnd(14)} ${detail}`;
    if (state === 'this-note') console.log(line);
    else console.warn(line);
  }

  const auth = await read(token, 'auth.test');
  if (auth.ok !== true) {
    console.error(`caller     unreadable     auth.test: ${auth.error}`);
    process.exitCode = 2;
    return;
  }
  const userId = auth.user_id;
  if (auth.bot_id) {
    console.warn(`caller     ${String(userId).padEnd(16)} this is a bot token; admin.* `
      + 'wants a user token held by an org Admin or Owner');
  }
  console.log(`caller     ${String(userId).padEnd(16)} auth.test returned the account `
    + 'this token belongs to');

  const body = await read(token, 'users.info', { user: userId });
  if (body.ok !== true) {
    console.error(`role       unreadable     users.info: ${body.error}`);
    process.exitCode = 2;
    return;
  }
  const role = roles(body.user);
  console.log(`role       workspace        admin=${role.workspace_admin} `
    + `owner=${role.workspace_owner} primary_owner=${role.primary_owner}`);
  if (role.org_role_known) {
    console.log(`role       organization     admin=${role.org_admin} `
      + `owner=${role.org_owner}  (from enterprise_user)`);
  } else {
    console.warn('role       organization     not readable; the user object carries no '
      + 'enterprise_user');
  }

  const [state, why] = satisfies(role);
  const roleLine = `admin.*    ${state.padEnd(16)} ${why}`;
  if (state === 'yes') console.log(roleLine);
  else console.warn(roleLine);

  const [drift, detail] = roleDrift(arg(args, '--recorded-role'), role);
  const driftLine = `drift      ${drift.padEnd(16)} ${detail}`;
  if (drift === 'stable' || drift === 'gained') console.log(driftLine);
  else console.warn(driftLine);

  const teamId = arg(args, '--team-id');
  if (teamId && state !== 'yes') {
    const page = await read(token, 'users.list', { team_id: teamId, limit: 200 });
    const [people, coverage] = candidates(page.members ?? []);
    for (const [user, label] of people) {
      console.log(`candidate  ${String(user).padEnd(16)} ${label}`);
    }
    if (!people.length) {
      console.warn('candidate  none found       no member on this page showed the '
        + 'organization role');
    }
    console.log(`coverage   ${coverage[0].padEnd(16)} ${coverage[1]}`);
  }

  const [final, summary] = verdict(state, drift);
  if (final === 'clean') {
    console.log(`verdict    clean            ${summary}`);
    return;
  }
  console.warn(`verdict    ${final.padEnd(16)} ${summary}`);
  console.warn('  repair: have an org Admin or Owner re-run the installation so the '
    + 'user token belongs to a privileged account');
  console.warn("  repair: prefer a dedicated service account holding the org role over "
    + "an individual's account, which changes when they do");
  console.warn('  repair: assert the org role at startup and fail loudly, so the next '
    + 'role change is a boot error rather than a silent afternoon');
  process.exitCode = 1;
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main().
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The fixtures are user objects, which is all this check reads, and the assertion that matters most is the one about absence: a workspace admin with no enterprise_user object must come back unknown rather than no, because that shape is what every non-Grid workspace looks like and a checker that faults it will fault every customer on a smaller plan. The mirror of it is that a member with a readable org role and both flags false must come back no, since that is the actual finding. After those, the drift table — where losing the role has to be distinguishable from never having recorded one — and the candidate scan, which has to report partial coverage rather than pretending a short list is the whole list.

test_slack_admin_role.py
from slack_admin_role import (
    candidates, explains, role_drift, roles, satisfies, verdict,
)

ORG_ADMIN = {"id": "U1", "is_admin": True,
             "enterprise_user": {"id": "W1", "is_admin": True, "is_owner": False}}
ORG_MEMBER = {"id": "U2", "is_admin": True,
              "enterprise_user": {"id": "W2", "is_admin": False, "is_owner": False}}
NO_ENTERPRISE = {"id": "U3", "is_admin": True, "is_owner": False}


def test_the_two_is_admin_fields_are_read_separately():
    role = roles(ORG_MEMBER)
    assert role["workspace_admin"] is True
    assert role["org_admin"] is False
    assert role["org_role_known"] is True


def test_an_org_admin_satisfies_the_methods():
    assert satisfies(roles(ORG_ADMIN))[0] == "yes"


def test_a_workspace_admin_with_a_readable_org_role_does_not():
    state, why = satisfies(roles(ORG_MEMBER))
    assert state == "no"
    assert "workspace admin is a different role" in why


def test_a_missing_enterprise_user_is_unknown_and_never_a_failure():
    state, why = satisfies(roles(NO_ENTERPRISE))
    assert state == "unknown"
    assert "could not be read" in why


def test_a_deactivated_account_holds_no_role():
    assert satisfies(roles({"id": "U9", "deleted": True}))[0] == "no"


def test_a_bot_account_is_refused_with_its_own_reason():
    state, why = satisfies(roles({"id": "B1", "is_bot": True}))
    assert state == "no"
    assert "bot account" in why


def test_an_org_owner_counts_even_without_the_admin_flag():
    owner = {"id": "U4", "enterprise_user": {"is_admin": False, "is_owner": True}}
    assert satisfies(roles(owner))[0] == "yes"


def test_losing_the_role_is_the_finding_that_explains_a_silent_break():
    state, detail = role_drift("org-admin", roles(ORG_MEMBER))
    assert state == "lost"
    assert "reads member now" in detail


def test_never_recording_the_role_is_not_the_same_as_losing_it():
    assert role_drift("", roles(ORG_MEMBER))[0] == "unrecorded"


def test_drift_is_unknown_when_the_org_role_cannot_be_read():
    assert role_drift("org-admin", roles(NO_ENTERPRISE))[0] == "unknown"


def test_a_stable_role_says_so():
    assert role_drift("org-admin", roles(ORG_ADMIN))[0] == "stable"


def test_candidates_name_the_people_who_can_reinstall():
    members = [ORG_ADMIN, ORG_MEMBER,
               {"id": "U5", "enterprise_user": {"is_owner": True}},
               {"id": "B2", "is_bot": True,
                "enterprise_user": {"is_admin": True}}]
    people, coverage = candidates(members)
    assert ("U1", "org admin") in people
    assert ("U5", "org owner") in people
    assert coverage[0] == "full"


def test_candidate_coverage_is_partial_when_members_lack_the_object():
    people, coverage = candidates([ORG_ADMIN, NO_ENTERPRISE])
    assert people == [("U1", "org admin")]
    assert coverage[0] == "partial"
    assert "1 of 2" in coverage[1]


def test_this_note_explains_one_refusal_and_hands_over_the_rest():
    assert explains("not_an_admin")[0] == "this-note"
    assert explains("feature_not_enabled") == ("elsewhere", "/slack/feature-not-enabled/")
    assert explains("nonsense_error")[0] == "unknown"


def test_the_verdict_is_inconclusive_rather_than_clean_when_unknown():
    assert verdict("unknown", "unrecorded")[0] == "inconclusive"


def test_two_findings_are_counted_together():
    assert verdict("no", "lost")[0] == "2 finding(s)"


def test_a_privileged_account_with_a_stable_role_is_clean():
    assert verdict("yes", "stable")[0] == "clean"
slack-admin-role.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  candidates, explains, roleDrift, roles, satisfies, verdict,
} from './slack-admin-role.mjs';

const ORG_ADMIN = { id: 'U1', is_admin: true,
  enterprise_user: { id: 'W1', is_admin: true, is_owner: false } };
const ORG_MEMBER = { id: 'U2', is_admin: true,
  enterprise_user: { id: 'W2', is_admin: false, is_owner: false } };
const NO_ENTERPRISE = { id: 'U3', is_admin: true, is_owner: false };

test('the two is_admin fields are read separately', () => {
  const role = roles(ORG_MEMBER);
  assert.equal(role.workspace_admin, true);
  assert.equal(role.org_admin, false);
  assert.equal(role.org_role_known, true);
});

test('an org admin satisfies the methods', () => {
  assert.equal(satisfies(roles(ORG_ADMIN))[0], 'yes');
});

test('a workspace admin with a readable org role does not', () => {
  const [state, why] = satisfies(roles(ORG_MEMBER));
  assert.equal(state, 'no');
  assert.match(why, /workspace admin is a different role/);
});

test('a missing enterprise_user is unknown and never a failure', () => {
  const [state, why] = satisfies(roles(NO_ENTERPRISE));
  assert.equal(state, 'unknown');
  assert.match(why, /could not be read/);
});

test('a deactivated account holds no role', () => {
  assert.equal(satisfies(roles({ id: 'U9', deleted: true }))[0], 'no');
});

test('a bot account is refused with its own reason', () => {
  const [state, why] = satisfies(roles({ id: 'B1', is_bot: true }));
  assert.equal(state, 'no');
  assert.match(why, /bot account/);
});

test('an org owner counts even without the admin flag', () => {
  const owner = { id: 'U4', enterprise_user: { is_admin: false, is_owner: true } };
  assert.equal(satisfies(roles(owner))[0], 'yes');
});

test('losing the role is the finding that explains a silent break', () => {
  const [state, detail] = roleDrift('org-admin', roles(ORG_MEMBER));
  assert.equal(state, 'lost');
  assert.match(detail, /reads member now/);
});

test('never recording the role is not the same as losing it', () => {
  assert.equal(roleDrift('', roles(ORG_MEMBER))[0], 'unrecorded');
});

test('drift is unknown when the org role cannot be read', () => {
  assert.equal(roleDrift('org-admin', roles(NO_ENTERPRISE))[0], 'unknown');
});

test('candidates name the people who can reinstall', () => {
  const members = [ORG_ADMIN, ORG_MEMBER,
    { id: 'U5', enterprise_user: { is_owner: true } },
    { id: 'B2', is_bot: true, enterprise_user: { is_admin: true } }];
  const [people, coverage] = candidates(members);
  assert.deepEqual(people, [['U1', 'org admin'], ['U5', 'org owner']]);
  assert.equal(coverage[0], 'full');
});

test('candidate coverage is partial when members lack the object', () => {
  const [people, coverage] = candidates([ORG_ADMIN, NO_ENTERPRISE]);
  assert.deepEqual(people, [['U1', 'org admin']]);
  assert.equal(coverage[0], 'partial');
  assert.match(coverage[1], /1 of 2/);
});

test('this note explains one refusal and hands over the rest', () => {
  assert.equal(explains('not_an_admin')[0], 'this-note');
  assert.deepEqual(explains('feature_not_enabled'),
    ['elsewhere', '/slack/feature-not-enabled/']);
  assert.equal(explains('nonsense_error')[0], 'unknown');
});

test('the verdict is inconclusive rather than clean when unknown', () => {
  assert.equal(verdict('unknown', 'unrecorded')[0], 'inconclusive');
});

test('two findings are counted together', () => {
  assert.equal(verdict('no', 'lost')[0], '2 finding(s)');
});

test('a privileged account with a stable role is clean', () => {
  assert.equal(verdict('yes', 'stable')[0], 'clean');
});

FAQ

My Slack profile says I am an admin. Why does Slack say I am not?

Because there are two roles with almost the same name. The is_admin field at the root of the user object describes your role in one workspace, and it is the one your profile badge reflects. Organization methods check the role in the enterprise_user object, which is a different field describing whether you are an Admin or Owner of the whole Enterprise organization. Somebody can be a workspace admin in every workspace an organization has and still not hold the org role, and admin.* refuses them.

The user object has no enterprise_user at all. Does that mean no role?

No, and treating it as one is the main way this check goes wrong. That object is absent for two quite different reasons: the workspace is not part of an Enterprise organization, or the token could not read the org identity. Neither is evidence about the person. The script reports unknown, and if the workspace is genuinely not on Enterprise Grid the real answer is that the admin API is not available at all on that plan, which is its own note.

It worked for seven months and then stopped, with no deploy. What changed?

Almost certainly the person. Role is a property of a human, not of the installation, so when the account that authorised the install loses the org role or leaves the organization, every admin.* call starts returning not_an_admin with nothing on your side having changed. That is what the drift line is for, and it can only be read if you stored the role at install time. If you did not, storing it now makes the next occurrence diagnosable in one line.

Can I grant the role from the API to fix this?

No, and nothing in this section would. Role assignment is an administrative action inside the customer's organization, and it is theirs to make. The most useful thing an integration can do is produce the sentence its administrator needs: this account holds the workspace role and not the organization role, and here are the accounts that do hold it. That is the candidate list, and it turns a failed API call into a request somebody can action.

Should we install with a service account instead of a person's account?

For anything organization-wide, yes. An individual's account ties a production capability to one person's role and one person's continued employment, and the offboarding process that disables the account will break the integration without anybody connecting the two. A dedicated account holding the org role survives reorganisations and departures, and it appears in the organization's own access review as what it is, which an engineer's personal token does not.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.