Skip to content

Diagnostic Slack

team_access_not_granted: the token reaches one workspace

The customer is one company with one contract and one invoice. Inside Slack they are forty workspaces in an Enterprise Grid organization, and your app was installed into exactly one of them, on a Tuesday, by somebody in Marketing who was trying to get a notification working.

Six weeks later the rollout starts and every call outside Marketing comes back {"ok": false, "error": "team_access_not_granted"}: the token used is not granted the specific workspace access required. The token is valid. The scopes are right. Nothing is expired, revoked or restricted. The token simply has a boundary, that boundary is one workspace, and nothing in your code has ever had a reason to know that boundaries exist.

Read-only token Python and Node.js Tests included
A phone showing app icons
Photo by Mika Baumeister on Unsplash
The short answer

On Enterprise Grid an app is installed either org-wide or into a single workspace, and the two produce tokens with different reach. A workspace-scoped token can see that workspace's conversations and members and nothing else; point it at a channel id or a user id from a sibling workspace and Slack answers team_access_not_granted.

auth.test tells you which one you are holding, in two fields read together. is_enterprise_install: true is an org-wide token. is_enterprise_install: false with a non-null enterprise_id is the configuration that produces this error: a workspace install sitting inside a Grid organization, which behaves exactly like a normal install right up to the moment somebody outside that workspace touches it.

The repair has two halves and the second one catches people out. Installing org-wide, with settings.org_deploy_enabled: true, mints a token that spans workspaces — and then several methods that were previously unambiguous start requiring a team_id parameter, because a token that can see forty workspaces has to be told which one you meant. Code that has never sent team_id in its life keeps working in the home workspace and returns the wrong workspace's data, or nothing at all, everywhere else.

The problem in plain words

The first difficulty is that the failure has a shape nobody expects: it is per resource, not per call. The same token, in the same process, on the same line of code, succeeds for one channel and is refused for the next, and the only difference between them is which workspace of the customer's organization the channel lives in. That is not how tokens usually behave. A token is normally either good or not, so the natural first hypothesis is that something is intermittent, and an afternoon disappears into retry logic.

The second is that Grid is invisible from inside the workspace where everything works. A single-workspace install into a Grid org looks identical to an install into an ordinary standalone workspace: same token prefix, same scopes, same team_id, same everything. The only tell is the enterprise_id field sitting in the auth.test response, which nobody reads, because until the day this happens there has been no reason to.

The third is that the ambiguity is real and unfixable. A read against a channel your token cannot reach may answer team_access_not_granted, which is unambiguous and useful. It may equally answer channel_not_found, which means either that the channel does not exist or that you are not permitted to know whether it exists — and the API deliberately will not tell you which. Any check here has to report that as ambiguous rather than guessing, because guessing produces a confident answer that is wrong roughly half the time.

And the fourth is that the fix is somebody else's to make. Installing org-wide happens at the organization level, in an admin console you do not have access to, at a customer whose Slack administrators may be three approvals away from the person who filed the ticket. That makes the output of a diagnostic here unusually important: it is not a repair you apply, it is a sentence somebody has to be able to forward.

Installed intoone workspaceby somebody inMarketingIt worksperfectlyfor six weeksThe rolloutbeginschannels in asiblingteam_access_not_grantedthe token has aboundaryRetried, andrefusedthe boundary doesnot move
The same token, the same code and the same line succeed for one channel and are refused for the next. The only difference is which workspace of the customer the channel lives in.

Why it happens

Two fields read together, not one. is_enterprise_install alone does not identify the problem, because false is also the correct value for every ordinary workspace on the planet. enterprise_id alone does not either, because it is populated for org-wide installs too. It is the pair — false with a non-null enterprise_id — that names a workspace install inside a Grid org, which is the one configuration that produces this error.

A refusal and an invisibility are different findings and the script never merges them. team_access_not_granted means the resource exists and you are outside its boundary. channel_not_found means either it does not exist or you are not allowed to know, and Slack will not say which. Reporting the second as the first turns an honest “cannot tell” into a confident wrong answer, and the reader has no way to notice.

Reach is measured per workspace, because the boundary is per workspace. One refusal proves the token has a boundary; it does not tell you where the boundary is. Probing each workspace in the organization produces a map: reachable, refused, or unknown, workspace by workspace, and that map is what tells you whether you are looking at one missing install or at a token that only ever covered its home.

The team_id half is checked before the org-wide install, not after. Going org-wide is the repair for the boundary and the cause of the next problem, because several methods that were unambiguous under a workspace token require disambiguation under an org-wide one. So the script reports which of the methods you named will need team_id, while you can still schedule that work alongside the install rather than discovering it in production the following week.

Nothing here writes, and the probe is chosen for that reason. team.info?team=T... is a read that answers the reach question directly for any workspace id you can name, and it fails with exactly the error under investigation. Installing an app, deploying it org-wide, or joining a channel to test membership are all writes, and none of them is necessary to establish the answer.

Storing the installation is a different note and is handed over. Whether your database can represent an org-wide install at all — whether it is keyed on team_id alone, and what happens when two workspaces in the same organization collide — is a data-modelling problem with its own failure mode and its own note. This one stops at the reach of the token in your hand.

The fix, as a flow

The red arrow is in the middle, and the six weeks before it are not a grace period. The token was always bounded; nothing had crossed the boundary yet. That is what makes this hard to recognise from inside the workspace where it works, because a single-workspace install into a Grid organization is indistinguishable from an ordinary install in every field except one that nobody reads. The fix branch sorts workspaces, one read each, and its third row is the one to respect: a resource that answers not_found may be absent or may be invisible, Slack declines to say which, and a script that picks one is confidently wrong about half the time.

Each workspace, one readteam.info, nothing writtenteam_access_not_grantedoutside the boundary, exactlyOnly the home workspaceone of forty, and no moreAnswers not_foundabsent, or invisible, unknownOrg wide, and no team_idit answers for the wrong oneEvery workspace answersthe install spans the org
One refusal proves the token has an edge. A workspace by workspace map says where the edge is, which is the sentence an administrator can act on, and nothing in it is written to find out.

How to fix it

Read the pair of fields that identify the shape

install_shape takes the auth.test body and answers org-wide, workspace-in-grid, single-workspace or unreadable. workspace-in-grid is the finding: an install bounded to one workspace of an organization that has many.

Get a list of the organization's workspace ids from somewhere

The script probes whatever you pass to --teams, because there is no way for it to enumerate them from a token that cannot reach them. Where you get the ids from is admin.teams.list, which needs admin.teams:read on a Grid user token, or the team_id field on the event payloads you are already receiving.

Probe each workspace with a read that fails in the right way

team.info?team=T... answers the reach question directly and writes nothing. reach_verdict maps the response to in-reach, out-of-reach, ambiguous or not-assessed, and it refuses to convert the third into either of the first two.

Read the map rather than the individual results

reach_summary answers org-wide-reach, single-workspace-reach, partial or not-assessed. Single-workspace reach across an organization of forty is the sentence to send the customer's Slack administrator, and it is considerably more persuasive than one error string.

Find out which of your calls will need team_id afterwards

team_id_requirement takes the methods you actually call and says which of them require team_id once the token is org-wide. Doing this before the install means the parameter work ships with the change rather than a week after it.

Install org-wide, or install per workspace and store one token each

repair_plan prints both routes, because org-wide is not always available. Where it is, the manifest key is settings.org_deploy_enabled: true and the install happens at the organization level. Where it is not, the answer is one install and one stored token per workspace, which is a data-modelling change rather than a configuration one.

How to check it worked

After the org-wide install, re-run with the same workspace list. Every row should read in-reach, and the shape line should read org-wide.

python3 slack_grid_token_reach.py --teams T04MKTG,T04ENG,T04SUP \
  --methods conversations.list,users.list,chat.scheduledMessages.list
# shape      workspace-in-grid  is_enterprise_install is false and enterprise_id is
#                               E04NORTHWIND, so this install is bound to one
#                               workspace of an organization that has many
# home       T04MKTG            the workspace this token was installed into
# reach      T04MKTG            in-reach
# reach      T04ENG             out-of-reach   team_access_not_granted
# reach      T04SUP             ambiguous      channel_not_found tells you either that
#                               it does not exist or that you may not know
# summary    single-workspace-reach  1 of 3 workspace(s) reachable
# team_id    conversations.list required once the token is org-wide
# team_id    users.list         required once the token is org-wide
# team_id    chat.scheduledMessages.list  required once the token is org-wide
# verdict    2 finding(s)
#   repair: install the app at the organization level, with
#           settings.org_deploy_enabled: true in the manifest
#   repair: pass team_id explicitly on the 3 method(s) above once the token spans
#           workspaces, or they will answer for the wrong one
#   note:   1 workspace was ambiguous; channel_not_found and user_not_found cannot
#           distinguish absent from invisible

The full code

Four small functions and one deliberate refusal. install_shape reads two fields together, because neither one identifies the configuration on its own. reach_verdict is where the refusal lives: channel_not_found is reported as ambiguous and never promoted to a finding, because the API genuinely will not say whether the resource is absent or merely invisible. reach_summary turns per-workspace results into the sentence you send an administrator, and team_id_requirement answers the question that only becomes urgent after the repair.

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_grid_token_reach.py
"""Work out how far one Slack token reaches across an Enterprise Grid org.

Read only. Two GET methods are used: auth.test to identify the install shape,
and team.info per workspace to probe reach. team.info is chosen because it is a
read that fails with exactly the error under investigation, so no write and no
install is needed to establish the answer.

One ambiguity is preserved rather than resolved. A read against a resource your
token cannot reach may answer team_access_not_granted, which is unambiguous, or
channel_not_found, which means either that the resource does not exist or that
you are not permitted to know whether it exists. Slack will not say which, so
neither does this script.
"""
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_grid_token_reach")

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

# The error that names the boundary directly.
BOUNDARY_ERROR = "team_access_not_granted"

# Errors that could mean "it is not there" or "you may not know it is there".
# The API does not distinguish them and neither does this script.
AMBIGUOUS_ERRORS = ("channel_not_found", "user_not_found", "team_not_found",
                    "not_in_channel")

# Errors about the credential rather than about reach.
UNASSESSABLE_ERRORS = ("missing_scope", "not_allowed_token_type", "invalid_auth",
                       "token_revoked", "account_inactive", "ratelimited",
                       "unparseable_body")

# Methods that take an optional team_id which becomes necessary once the token
# spans workspaces, because a token that can see forty of them has to be told
# which one you meant. This is the set the script knows about; a method outside
# it is reported as unknown rather than guessed at.
#
# It is a working subset, not the authority. Slack publishes the full table on
# its Enterprise Grid apps page, and that page is what to check before an
# org-wide install -- a name absent from here means this script has not been
# told about it, never that the parameter is unnecessary.
REQUIRES_TEAM_ID = ("conversations.list", "users.list", "usergroups.list",
                    "users.conversations", "emoji.list", "dnd.teamInfo",
                    "team.profile.get", "chat.scheduledMessages.list",
                    "team.billableInfo")
ACCEPTS_TEAM_ID = ("team.info", "admin.conversations.search", "admin.users.list")


def install_shape(auth):
    """Which of the three install shapes is this token? Pure.

    Takes an auth.test body. Returns (state, detail). The two fields are read
    together because neither identifies the configuration alone:
    is_enterprise_install is false for every ordinary workspace on the planet,
    and enterprise_id is populated for org-wide installs too.

      org-wide           is_enterprise_install is true; the token spans the org.
      workspace-in-grid  false, with an enterprise_id. This is the finding.
      single-workspace   no enterprise_id; Grid is not involved at all.
      unreadable         auth.test did not answer.
    """
    doc = auth or {}
    if doc.get("ok") is not True:
        return ("unreadable", "auth.test answered ok: false, error=%s"
                % (doc.get("error") or "none"))
    enterprise = doc.get("enterprise_id") or ""
    if doc.get("is_enterprise_install") is True:
        return ("org-wide", "is_enterprise_install is true, so this token spans the "
                            "workspaces of %s and several methods will need a "
                            "team_id" % (enterprise or "the organization"))
    if enterprise:
        return ("workspace-in-grid", "is_enterprise_install is false and "
                                     "enterprise_id is %s, so this install is bound "
                                     "to one workspace of an organization that has "
                                     "many" % enterprise)
    return ("single-workspace", "no enterprise_id, so this is an ordinary workspace "
                                "install and Grid is not involved")


def reach_verdict(body):
    """Can this token see the workspace the probe named? Pure.

    Returns (state, detail).

      in-reach      the read succeeded.
      out-of-reach  team_access_not_granted: the boundary, named exactly.
      ambiguous     an error that cannot distinguish absent from invisible.
      not-assessed  the call failed for a reason about the credential.
      other         anything else, handed on rather than absorbed.
    """
    doc = body or {}
    if doc.get("ok") is True:
        return ("in-reach", "the read succeeded, so this workspace is inside the "
                            "token's boundary")
    error = str(doc.get("error") or "")
    if error == BOUNDARY_ERROR:
        return ("out-of-reach", "%s: the resource exists and the token is outside "
                                "its workspace" % BOUNDARY_ERROR)
    if error in AMBIGUOUS_ERRORS:
        return ("ambiguous", "%s tells you either that it does not exist or that you "
                             "may not know; Slack does not distinguish them" % error)
    if error in UNASSESSABLE_ERRORS:
        return ("not-assessed", "%s is about the credential rather than about reach"
                % error)
    if not error:
        return ("not-assessed", "no ok field and no error, so nothing can be read "
                                "from this response")
    return ("other", "%s, which is a different problem" % error)


def reach_summary(rows, home_team=""):
    """Turn per-workspace results into one sentence. Pure.

    rows: [(team_id, state), ...]. Returns (state, counts).

      org-wide-reach          every workspace probed is reachable.
      single-workspace-reach  only the home workspace is, which is the shape this
                              note is about.
      partial                 some are and some are not.
      not-assessed            nothing conclusive came back.
      no-workspaces           nothing was probed.
    """
    seen = [(str(t), str(s)) for t, s in (rows or [])]
    counts = {"workspaces": len(seen), "in_reach": 0, "out_of_reach": 0,
              "ambiguous": 0, "not_assessed": 0}
    for _team, state in seen:
        if state == "in-reach":
            counts["in_reach"] += 1
        elif state == "out-of-reach":
            counts["out_of_reach"] += 1
        elif state == "ambiguous":
            counts["ambiguous"] += 1
        else:
            counts["not_assessed"] += 1
    if not seen:
        return ("no-workspaces", counts)
    if not counts["in_reach"] and not counts["out_of_reach"]:
        return ("not-assessed", counts)
    if not counts["out_of_reach"]:
        return ("org-wide-reach", counts)
    reachable = [t for t, s in seen if s == "in-reach"]
    if reachable and reachable == [home_team]:
        return ("single-workspace-reach", counts)
    if not counts["in_reach"]:
        return ("single-workspace-reach", counts)
    return ("partial", counts)


def team_id_requirement(method, org_wide):
    """Will this method need a team_id once the token spans workspaces? Pure.

    Returns (state, detail). Asked before the org-wide install rather than
    after it, so the parameter work ships with the change instead of a week
    later.
    """
    name = str(method or "").strip()
    known = name in REQUIRES_TEAM_ID or name in ACCEPTS_TEAM_ID
    if not known:
        return ("unknown-method", "this method is not in the list this script knows "
                                  "about; check its reference page for a team_id "
                                  "parameter before the install")
    if not org_wide:
        return ("unnecessary", "a workspace-scoped token has one workspace to answer "
                               "for, so the call is bounded whether team_id is sent "
                               "or not")
    if name in REQUIRES_TEAM_ID:
        return ("required", "required once the token is org-wide; without it the "
                            "call has forty workspaces to choose from. Confirm "
                            "against Slack's own table, which is the authority "
                            "this list is only a subset of")
    return ("optional", "accepted once the token is org-wide, and worth sending so "
                        "the call cannot answer for the wrong workspace")


def repair_plan(shape, summary, needing_team_id):
    """The two routes, printed as lines somebody can forward. Pure."""
    out = []
    if shape == "workspace-in-grid" or summary in ("single-workspace-reach", "partial"):
        out.append("install the app at the organization level, with "
                   "settings.org_deploy_enabled: true in the manifest")
        out.append("where an org-wide install is not possible, install per workspace "
                   "and store one token for each")
    if needing_team_id:
        out.append("pass team_id explicitly on the %d method(s) above once the token "
                   "spans workspaces, or they will answer for the wrong one"
                   % len(needing_team_id))
    return out


def get(session, method, params=None):
    """One GET against the Web API. 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_BOT_TOKEN",
                    help="environment variable holding the token to test")
    ap.add_argument("--teams", default="",
                    help="comma separated workspace ids to probe, T...; collected "
                         "from event payloads if you cannot list them")
    ap.add_argument("--methods", default="",
                    help="comma separated methods your app calls, to check which "
                         "will need a team_id once the token is org-wide")
    args = ap.parse_args()

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

    auth = get(session, "auth.test")
    shape, detail = install_shape(auth)
    (log.info if shape in ("org-wide", "single-workspace") else log.warning)(
        "shape      %-18s %s", shape, detail)
    if shape == "unreadable":
        return 2
    home = auth.get("team_id") or ""
    log.info("home       %-18s the workspace this token was installed into", home)

    rows = []
    teams = [t.strip() for t in args.teams.split(",") if t.strip()]
    for team in teams:
        state, why = reach_verdict(get(session, "team.info", {"team": team}))
        (log.info if state == "in-reach" else log.warning)(
            "reach      %-18s %-14s %s", team, state, why)
        rows.append((team, state))

    summary, counts = reach_summary(rows, home_team=home)
    (log.info if summary in ("org-wide-reach", "no-workspaces") else log.warning)(
        "summary    %-18s %d of %d workspace(s) reachable", summary,
        counts["in_reach"], counts["workspaces"])

    org_wide = shape == "org-wide"
    needing = []
    for method in [m.strip() for m in args.methods.split(",") if m.strip()]:
        state, why = team_id_requirement(method, org_wide or shape == "workspace-in-grid")
        if state in ("required", "optional"):
            needing.append(method)
        (log.info if state == "unnecessary" else log.warning)(
            "team_id    %-18s %s: %s", method, state, why)

    repairs = repair_plan(shape, summary, needing)
    findings = (1 if shape == "workspace-in-grid" else 0) + (1 if needing else 0)
    if not findings and summary != "partial":
        log.info("verdict    clean              the token reaches every workspace it "
                 "was asked about")
        return 0
    log.warning("verdict    %d finding(s)", findings)
    for line in repairs:
        log.warning("  repair: %s", line)
    if counts["ambiguous"]:
        log.warning("  note:   %d workspace(s) were ambiguous; channel_not_found and "
                    "user_not_found cannot distinguish absent from invisible",
                    counts["ambiguous"])
    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-grid-token-reach.mjs
/**
 * Work out how far one Slack token reaches across an Enterprise Grid org.
 *
 * Read only. Two GET methods are used: auth.test to identify the install
 * shape, and team.info per workspace to probe reach. team.info is chosen
 * because it is a read that fails with exactly the error under investigation,
 * so no write and no install is needed to establish the answer.
 *
 * One ambiguity is preserved rather than resolved: channel_not_found means
 * either that the resource does not exist or that you may not know it exists,
 * and Slack will not say which.
 */

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

// The error that names the boundary directly.
export const BOUNDARY_ERROR = 'team_access_not_granted';

// Errors that could mean "not there" or "you may not know it is there".
export const AMBIGUOUS_ERRORS = ['channel_not_found', 'user_not_found',
  'team_not_found', 'not_in_channel'];

// Errors about the credential rather than about reach.
export const UNASSESSABLE_ERRORS = ['missing_scope', 'not_allowed_token_type',
  'invalid_auth', 'token_revoked', 'account_inactive', 'ratelimited',
  'unparseable_body'];

// Methods whose optional team_id becomes necessary once the token spans
// workspaces. A method outside these lists is reported as unknown.
export const REQUIRES_TEAM_ID = ['conversations.list', 'users.list',
  'usergroups.list', 'users.conversations', 'emoji.list', 'dnd.teamInfo',
  'team.profile.get', 'chat.scheduledMessages.list', 'team.billableInfo'];
export const ACCEPTS_TEAM_ID = ['team.info', 'admin.conversations.search',
  'admin.users.list'];

/**
 * Which of the three install shapes is this token? Pure.
 * Returns [state, detail]; org-wide, workspace-in-grid, single-workspace,
 * unreadable.
 */
export function installShape(auth) {
  const doc = auth ?? {};
  if (doc.ok !== true) {
    return ['unreadable',
      `auth.test answered ok: false, error=${doc.error ?? 'none'}`];
  }
  const enterprise = doc.enterprise_id ?? '';
  if (doc.is_enterprise_install === true) {
    return ['org-wide', 'is_enterprise_install is true, so this token spans the '
      + `workspaces of ${enterprise || 'the organization'} and several methods will `
      + 'need a team_id'];
  }
  if (enterprise) {
    return ['workspace-in-grid', 'is_enterprise_install is false and enterprise_id '
      + `is ${enterprise}, so this install is bound to one workspace of an `
      + 'organization that has many'];
  }
  return ['single-workspace', 'no enterprise_id, so this is an ordinary workspace '
    + 'install and Grid is not involved'];
}

/**
 * Can this token see the workspace the probe named? Pure.
 * Returns [state, detail]; in-reach, out-of-reach, ambiguous, not-assessed, other.
 */
export function reachVerdict(body) {
  const doc = body ?? {};
  if (doc.ok === true) {
    return ['in-reach', 'the read succeeded, so this workspace is inside the '
      + "token's boundary"];
  }
  const error = String(doc.error ?? '');
  if (error === BOUNDARY_ERROR) {
    return ['out-of-reach', `${BOUNDARY_ERROR}: the resource exists and the token is `
      + 'outside its workspace'];
  }
  if (AMBIGUOUS_ERRORS.includes(error)) {
    return ['ambiguous', `${error} tells you either that it does not exist or that `
      + 'you may not know; Slack does not distinguish them'];
  }
  if (UNASSESSABLE_ERRORS.includes(error)) {
    return ['not-assessed', `${error} is about the credential rather than about reach`];
  }
  if (!error) {
    return ['not-assessed', 'no ok field and no error, so nothing can be read from '
      + 'this response'];
  }
  return ['other', `${error}, which is a different problem`];
}

/**
 * Turn per-workspace results into one sentence. Pure.
 * Returns [state, counts].
 */
export function reachSummary(rows, homeTeam = '') {
  const seen = (rows ?? []).map(([t, s]) => [String(t), String(s)]);
  const counts = {
    workspaces: seen.length,
    in_reach: 0,
    out_of_reach: 0,
    ambiguous: 0,
    not_assessed: 0,
  };
  for (const [, state] of seen) {
    if (state === 'in-reach') counts.in_reach += 1;
    else if (state === 'out-of-reach') counts.out_of_reach += 1;
    else if (state === 'ambiguous') counts.ambiguous += 1;
    else counts.not_assessed += 1;
  }
  if (!seen.length) return ['no-workspaces', counts];
  if (!counts.in_reach && !counts.out_of_reach) return ['not-assessed', counts];
  if (!counts.out_of_reach) return ['org-wide-reach', counts];
  const reachable = seen.filter(([, s]) => s === 'in-reach').map(([t]) => t);
  if (reachable.length === 1 && reachable[0] === homeTeam) {
    return ['single-workspace-reach', counts];
  }
  if (!counts.in_reach) return ['single-workspace-reach', counts];
  return ['partial', counts];
}

/** Will this method need a team_id once the token spans workspaces? Pure. */
export function teamIdRequirement(method, orgWide) {
  const name = String(method ?? '').trim();
  const known = REQUIRES_TEAM_ID.includes(name) || ACCEPTS_TEAM_ID.includes(name);
  if (!known) {
    return ['unknown-method', 'this method is not in the list this script knows '
      + 'about; check its reference page for a team_id parameter before the install'];
  }
  if (!orgWide) {
    return ['unnecessary', 'a workspace-scoped token has one workspace to answer for, '
      + 'so the call is bounded whether team_id is sent or not'];
  }
  if (REQUIRES_TEAM_ID.includes(name)) {
    return ['required', 'required once the token is org-wide; without it the call has '
      + 'forty workspaces to choose from'];
  }
  return ['optional', 'accepted once the token is org-wide, and worth sending so the '
    + 'call cannot answer for the wrong workspace'];
}

/** The two routes, printed as lines somebody can forward. Pure. */
export function repairPlan(shape, summary, needingTeamId) {
  const out = [];
  if (shape === 'workspace-in-grid' || summary === 'single-workspace-reach'
    || summary === 'partial') {
    out.push('install the app at the organization level, with '
      + 'settings.org_deploy_enabled: true in the manifest');
    out.push('where an org-wide install is not possible, install per workspace and '
      + 'store one token for each');
  }
  if ((needingTeamId ?? []).length) {
    out.push(`pass team_id explicitly on the ${needingTeamId.length} method(s) above `
      + 'once the token spans workspaces, or they will answer for the wrong one');
  }
  return out;
}

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

async function read(token, method, params) {
  const qs = new URLSearchParams(params ?? {}).toString();
  const r = await fetch(`${API}${method}${qs ? `?${qs}` : ''}`,
    { 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_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`token      missing            set ${tokenEnv} to the token you `
      + 'want to test');
    process.exitCode = 2;
    return;
  }

  const auth = await read(token, 'auth.test', {});
  const [shape, detail] = installShape(auth);
  const shapeLine = `shape      ${shape.padEnd(18)} ${detail}`;
  if (shape === 'org-wide' || shape === 'single-workspace') console.log(shapeLine);
  else console.warn(shapeLine);
  if (shape === 'unreadable') {
    process.exitCode = 2;
    return;
  }
  const home = auth.team_id ?? '';
  console.log(`home       ${String(home).padEnd(18)} the workspace this token was `
    + 'installed into');

  const rows = [];
  for (const team of arg(args, '--teams').split(',').map((t) => t.trim())
    .filter(Boolean)) {
    // eslint-disable-next-line no-await-in-loop
    const [state, why] = reachVerdict(await read(token, 'team.info', { team }));
    const line = `reach      ${team.padEnd(18)} ${state.padEnd(14)} ${why}`;
    if (state === 'in-reach') console.log(line);
    else console.warn(line);
    rows.push([team, state]);
  }

  const [summary, counts] = reachSummary(rows, home);
  const summaryLine = `summary    ${summary.padEnd(18)} ${counts.in_reach} of `
    + `${counts.workspaces} workspace(s) reachable`;
  if (summary === 'org-wide-reach' || summary === 'no-workspaces') {
    console.log(summaryLine);
  } else console.warn(summaryLine);

  const orgWide = shape === 'org-wide' || shape === 'workspace-in-grid';
  const needing = [];
  for (const method of arg(args, '--methods').split(',').map((m) => m.trim())
    .filter(Boolean)) {
    const [state, why] = teamIdRequirement(method, orgWide);
    if (state === 'required' || state === 'optional') needing.push(method);
    const line = `team_id    ${method.padEnd(18)} ${state}: ${why}`;
    if (state === 'unnecessary') console.log(line);
    else console.warn(line);
  }

  const findings = (shape === 'workspace-in-grid' ? 1 : 0) + (needing.length ? 1 : 0);
  if (!findings && summary !== 'partial') {
    console.log('verdict    clean              the token reaches every workspace it '
      + 'was asked about');
    return;
  }
  console.warn(`verdict    ${findings} finding(s)`);
  for (const line of repairPlan(shape, summary, needing)) {
    console.warn(`  repair: ${line}`);
  }
  if (counts.ambiguous) {
    console.warn(`  note:   ${counts.ambiguous} workspace(s) were ambiguous; `
      + 'channel_not_found and user_not_found cannot distinguish absent from '
      + 'invisible');
  }
  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 auth.test and team.info bodies, which contain no secret and are worth reading on their own: the difference between an org-wide install and a workspace install inside the same organization is two fields and nothing else. The assertion that matters most is the one about restraint. channel_not_found has to stay ambiguous in every combination, because promoting it to out-of-reach would give the script a confident answer where the API has refused to provide one.

test_slack_grid_token_reach.py
from slack_grid_token_reach import (
    install_shape, reach_summary, reach_verdict, repair_plan, team_id_requirement,
)

ORG = "E04NORTHWIND"
HOME = "T04MKTG"


def test_an_ordinary_workspace_install_is_not_a_grid_problem():
    state, detail = install_shape({"ok": True, "team_id": HOME})
    assert state == "single-workspace"
    assert "not involved" in detail


def test_an_org_wide_token_is_named_and_warned_about_team_id():
    state, detail = install_shape({"ok": True, "team_id": HOME,
                                   "enterprise_id": ORG,
                                   "is_enterprise_install": True})
    assert state == "org-wide"
    assert "team_id" in detail


def test_the_finding_needs_both_fields_read_together():
    state, detail = install_shape({"ok": True, "team_id": HOME,
                                   "enterprise_id": ORG,
                                   "is_enterprise_install": False})
    assert state == "workspace-in-grid"
    assert ORG in detail


def test_a_failed_auth_test_is_unreadable_rather_than_assumed():
    state, detail = install_shape({"ok": False, "error": "invalid_auth"})
    assert state == "unreadable"
    assert "invalid_auth" in detail


def test_the_boundary_error_is_the_one_unambiguous_answer():
    state, detail = reach_verdict({"ok": False, "error": "team_access_not_granted"})
    assert state == "out-of-reach"
    assert "outside its workspace" in detail


def test_a_successful_read_puts_the_workspace_inside_the_boundary():
    assert reach_verdict({"ok": True, "team": {"id": HOME}})[0] == "in-reach"


def test_not_found_is_kept_ambiguous_and_never_promoted():
    for error in ("channel_not_found", "user_not_found", "team_not_found"):
        state, detail = reach_verdict({"ok": False, "error": error})
        assert state == "ambiguous"
        assert "does not distinguish" in detail


def test_a_credential_problem_is_not_a_reach_problem():
    assert reach_verdict({"ok": False, "error": "missing_scope"})[0] == "not-assessed"
    assert reach_verdict({"ok": False, "error": "ratelimited"})[0] == "not-assessed"


def test_an_unrelated_error_is_handed_on():
    state, detail = reach_verdict({"ok": False, "error": "invalid_arguments"})
    assert state == "other"
    assert "different problem" in detail


def test_an_empty_body_is_not_assessed():
    assert reach_verdict({})[0] == "not-assessed"


def test_reaching_every_workspace_probed_is_org_wide_reach():
    state, counts = reach_summary([(HOME, "in-reach"), ("T04ENG", "in-reach")])
    assert state == "org-wide-reach"
    assert counts["in_reach"] == 2


def test_reaching_only_the_home_workspace_is_the_shape_of_this_note():
    state, counts = reach_summary(
        [(HOME, "in-reach"), ("T04ENG", "out-of-reach"), ("T04SUP", "ambiguous")],
        home_team=HOME)
    assert state == "single-workspace-reach"
    assert counts["ambiguous"] == 1


def test_reaching_some_but_not_all_is_partial():
    state, _counts = reach_summary(
        [(HOME, "in-reach"), ("T04ENG", "in-reach"), ("T04SUP", "out-of-reach")],
        home_team=HOME)
    assert state == "partial"


def test_a_sweep_of_only_ambiguous_answers_concludes_nothing():
    assert reach_summary([("T04ENG", "ambiguous"),
                          ("T04SUP", "not-assessed")])[0] == "not-assessed"


def test_probing_no_workspaces_says_so():
    assert reach_summary([])[0] == "no-workspaces"


def test_team_id_is_required_on_the_listing_methods_once_org_wide():
    state, detail = team_id_requirement("conversations.list", org_wide=True)
    assert state == "required"
    assert "org-wide" in detail


def test_team_id_is_merely_accepted_on_the_methods_that_take_it_optionally():
    assert team_id_requirement("team.info", org_wide=True)[0] == "optional"


def test_a_workspace_scoped_token_does_not_need_the_parameter_at_all():
    state, detail = team_id_requirement("users.list", org_wide=False)
    assert state == "unnecessary"
    assert "one workspace to answer for" in detail


def test_a_method_outside_the_list_is_not_guessed_at():
    state, detail = team_id_requirement("chat.postMessage", org_wide=True)
    assert state == "unknown-method"
    assert "reference page" in detail


def test_the_repair_prints_both_routes_when_the_token_is_bounded():
    lines = repair_plan("workspace-in-grid", "single-workspace-reach", [])
    assert any("org_deploy_enabled" in line for line in lines)
    assert any("per workspace" in line for line in lines)


def test_the_team_id_repair_counts_the_methods_it_applies_to():
    lines = repair_plan("org-wide", "org-wide-reach",
                        ["conversations.list", "users.list"])
    assert len(lines) == 1
    assert "2 method(s)" in lines[0]


def test_a_healthy_org_wide_token_needs_no_repair_at_all():
    assert repair_plan("org-wide", "org-wide-reach", []) == []
slack-grid-token-reach.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  installShape, reachSummary, reachVerdict, repairPlan, teamIdRequirement,
} from './slack-grid-token-reach.mjs';

const ORG = 'E04NORTHWIND';
const HOME = 'T04MKTG';

test('an ordinary workspace install is not a grid problem', () => {
  const [state, detail] = installShape({ ok: true, team_id: HOME });
  assert.equal(state, 'single-workspace');
  assert.match(detail, /not involved/);
});

test('an org wide token is named and warned about team_id', () => {
  const [state, detail] = installShape({
    ok: true, team_id: HOME, enterprise_id: ORG, is_enterprise_install: true,
  });
  assert.equal(state, 'org-wide');
  assert.match(detail, /team_id/);
});

test('the finding needs both fields read together', () => {
  const [state, detail] = installShape({
    ok: true, team_id: HOME, enterprise_id: ORG, is_enterprise_install: false,
  });
  assert.equal(state, 'workspace-in-grid');
  assert.match(detail, /E04NORTHWIND/);
});

test('a failed auth test is unreadable rather than assumed', () => {
  const [state, detail] = installShape({ ok: false, error: 'invalid_auth' });
  assert.equal(state, 'unreadable');
  assert.match(detail, /invalid_auth/);
});

test('the boundary error is the one unambiguous answer', () => {
  const [state, detail] = reachVerdict({
    ok: false, error: 'team_access_not_granted',
  });
  assert.equal(state, 'out-of-reach');
  assert.match(detail, /outside its workspace/);
});

test('a successful read puts the workspace inside the boundary', () => {
  assert.equal(reachVerdict({ ok: true, team: { id: HOME } })[0], 'in-reach');
});

test('not found is kept ambiguous and never promoted', () => {
  for (const error of ['channel_not_found', 'user_not_found', 'team_not_found']) {
    const [state, detail] = reachVerdict({ ok: false, error });
    assert.equal(state, 'ambiguous');
    assert.match(detail, /does not distinguish/);
  }
});

test('a credential problem is not a reach problem', () => {
  assert.equal(reachVerdict({ ok: false, error: 'missing_scope' })[0], 'not-assessed');
  assert.equal(reachVerdict({ ok: false, error: 'ratelimited' })[0], 'not-assessed');
});

test('an unrelated error is handed on', () => {
  const [state, detail] = reachVerdict({ ok: false, error: 'invalid_arguments' });
  assert.equal(state, 'other');
  assert.match(detail, /different problem/);
});

test('an empty body is not assessed', () => {
  assert.equal(reachVerdict({})[0], 'not-assessed');
});

test('reaching every workspace probed is org wide reach', () => {
  const [state, counts] = reachSummary([[HOME, 'in-reach'], ['T04ENG', 'in-reach']]);
  assert.equal(state, 'org-wide-reach');
  assert.equal(counts.in_reach, 2);
});

test('reaching only the home workspace is the shape of this note', () => {
  const [state, counts] = reachSummary([[HOME, 'in-reach'],
    ['T04ENG', 'out-of-reach'], ['T04SUP', 'ambiguous']], HOME);
  assert.equal(state, 'single-workspace-reach');
  assert.equal(counts.ambiguous, 1);
});

test('reaching some but not all is partial', () => {
  const [state] = reachSummary([[HOME, 'in-reach'], ['T04ENG', 'in-reach'],
    ['T04SUP', 'out-of-reach']], HOME);
  assert.equal(state, 'partial');
});

test('a sweep of only ambiguous answers concludes nothing', () => {
  assert.equal(reachSummary([['T04ENG', 'ambiguous'],
    ['T04SUP', 'not-assessed']])[0], 'not-assessed');
});

test('probing no workspaces says so', () => {
  assert.equal(reachSummary([])[0], 'no-workspaces');
});

test('team_id is required on the listing methods once org wide', () => {
  const [state, detail] = teamIdRequirement('conversations.list', true);
  assert.equal(state, 'required');
  assert.match(detail, /org-wide/);
});

test('team_id is merely accepted on the methods that take it optionally', () => {
  assert.equal(teamIdRequirement('team.info', true)[0], 'optional');
});

test('a workspace scoped token does not need the parameter at all', () => {
  const [state, detail] = teamIdRequirement('users.list', false);
  assert.equal(state, 'unnecessary');
  assert.match(detail, /one workspace to answer for/);
});

test('a method outside the list is not guessed at', () => {
  const [state, detail] = teamIdRequirement('chat.postMessage', true);
  assert.equal(state, 'unknown-method');
  assert.match(detail, /reference page/);
});

test('the repair prints both routes when the token is bounded', () => {
  const lines = repairPlan('workspace-in-grid', 'single-workspace-reach', []);
  assert.equal(lines.some((l) => l.includes('org_deploy_enabled')), true);
  assert.equal(lines.some((l) => l.includes('per workspace')), true);
});

test('the team_id repair counts the methods it applies to', () => {
  const lines = repairPlan('org-wide', 'org-wide-reach',
    ['conversations.list', 'users.list']);
  assert.equal(lines.length, 1);
  assert.match(lines[0], /2 method\(s\)/);
});

test('a healthy org wide token needs no repair at all', () => {
  assert.deepEqual(repairPlan('org-wide', 'org-wide-reach', []), []);
});

FAQ

How do I tell a Grid workspace install from an ordinary one?

By reading two fields of auth.test together, because neither is conclusive alone. is_enterprise_install is false for every ordinary workspace in the world, so it means nothing by itself. enterprise_id is populated for org-wide installs too, so it means nothing by itself either. It is the pair that identifies the configuration: is_enterprise_install false with a non-null enterprise_id is a workspace install sitting inside a Grid organization, which behaves exactly like a normal install until somebody outside that workspace touches it.

Why does the check refuse to say whether a channel_not_found means the channel is out of reach?

Because Slack refuses to say. That error covers both a resource that does not exist and a resource you are not permitted to know about, and the ambiguity is deliberate: telling you which one it was would leak the existence of private conversations to tokens that cannot see them. Any script that converts channel_not_found into out-of-reach is guessing, and it will be wrong roughly whenever the id was simply stale. Reporting ambiguous is less satisfying and considerably more useful, because it tells you the probe needs a resource you can confirm exists.

We installed org-wide and some calls started returning the wrong workspace's data. What happened?

You crossed into the second half of the repair. A workspace-scoped token has exactly one workspace to answer for, so a call like conversations.list is unambiguous whether or not you pass team_id. An org-wide token can see many, and several methods then need to be told which one you meant. Code that has never sent the parameter keeps working in the home workspace, because that is the default resolution, and answers for the wrong one elsewhere. Run the check with --methods before the install so the parameter work ships alongside it.

Is this the same as keying our installation store on team_id alone?

No, and they are worth keeping apart because one is about a token and the other is about a table. This note is about how far a single credential reaches: what one token can see, and what it is refused. The storage question is whether your database can even represent the answer, given that an org-wide install may have a null team_id and that two workspaces in the same organization can collide under a team_id key. That failure mode is cross-tenant data leakage rather than a refusal, and it has its own note, linked below.

The customer will not install org-wide. What are the options?

Install per workspace and store one token for each, which is supported and is what many apps do. The cost is in your data model rather than in your code: the installation store now holds several rows for one customer, your lookup has to resolve an incoming event to the right one, and anything that reports per customer has to aggregate across them. It is more work than an org-wide install and less work than discovering the requirement after the contract is signed, which is the usual alternative.

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.