Diagnostic Slack
enterprise_is_restricted: the method is barred on Grid
One customer, out of two hundred, is on Enterprise Grid. Their evaluation started on Monday and by Wednesday the integration engineer has a list of four methods that answer {"ok": false, "error": "enterprise_is_restricted"} — the method cannot be called from an Enterprise.
Nothing is wrong with the token. Nothing is wrong with the scopes. The person calling is an org owner. The refusal is attached to the method: some of the Web API is simply not callable in this context, the organization-level equivalents live somewhere else under different scopes and a different token class, and you are finding this out during a customer's evaluation because there was no earlier moment when you could have.
enterprise_is_restricted is documented, in the errors table of every method that carries it, as “The method cannot be called from an Enterprise.” It appears on ordinary workspace methods including chat.postMessage, conversations.list, conversations.history and users.info. It is not about scopes, not about admin roles, and not about which workspace you pointed at.
It is one of four different refusals that all appear on Grid and all need different repairs, and telling them apart is most of the work. enterprise_is_restricted means this method, here, no. team_access_not_granted means the token is bounded to a workspace and you reached outside it. org_login_required and team_added_to_org mean the workspace is migrating and will answer later. not_allowed_token_type means the wrong class of credential. A retry helps exactly one of those four.
The repair, where one exists, is an organization-level equivalent: admin.conversations.search in place of a cross-org conversations.list, admin.users.list in place of users.list, admin.teams.list in place of enumerating workspaces by hand. Those need admin.*:read scopes on an admin user token, which is a different credential from your bot token. Where no equivalent exists, the answer is to keep using the workspace-scoped path, per workspace, and accept the fan-out.
The problem in plain words
The first thing that makes this hard is that there is no published list. The set of methods that can answer enterprise_is_restricted is discoverable only by reading each method's own errors table, one page at a time, and the wording never varies enough to tell you why a particular method is on the list. So you cannot audit your call surface from the documentation in an afternoon; you can only look up the methods you already know you call, and hope the list you have is the list your code has.
The second is that the natural way to find out is to call everything, and half of your call surface writes. Probing conversations.list costs nothing. Probing chat.postMessage posts a message into a customer's workspace during their evaluation, probing conversations.create leaves a channel behind with whatever name the probe invented, and probing conversations.invite adds somebody to something. An audit that answers the question by trying is an audit that changes the thing it is auditing.
The third is the diagnosis, and it is where most of the lost time goes. Four different Grid refusals arrive through the same field of the same JSON body, and three of the four look like a permissions problem to anyone who has not met them before. Teams add scopes for a refusal that is not about scopes. They add retries for a refusal that is a decision. They re-install for a refusal that will clear on its own in six hours. Only org_login_required and team_added_to_org reward waiting, and only team_access_not_granted rewards a different install.
And the fourth is that the fix crosses a credential boundary. Every organization-level equivalent lives under admin.*, and those scopes exist only as user token scopes held by an org owner or admin. Swapping a call is a one-line change; obtaining the credential to make it with is an installation flow, an approval, and a conversation with somebody at the customer who has never heard of your app.
Why it happens
The unit is a method name, not an error. This check takes the list of methods your code calls — from a grep of your source, not from memory — and produces one row per method. The output is a table you can hand to whoever is doing the Grid work, and every row already carries its own repair.
Writes are named and never issued. Every method in the map is marked as a read or a write. Reads are probed. Writes are reported from the map with their expected classification and are not called, because there is no way to ask a write method whether it is barred except by writing, and this is somebody else's production organization.
The four Grid refusals are separated by a single function, because that separation is the finding. refusal_kind maps a response body to barred-on-enterprise, wrong-workspace, migration, token-class, scope or callable, and each of those prints a different next action. Collapsing them into “Grid problem” is what produces a week of adding scopes to a call that will never accept any.
The equivalence map is deliberately small and honest. It contains the substitutions that are actually equivalent and marks everything else no-equivalent, which means “iterate per workspace with a workspace-scoped token”. Inventing a plausible-looking admin.* replacement for a method that has none would be worse than saying nothing, because somebody would go and build it.
A method the map has never heard of is reported as unknown. Since Slack does not publish the restricted set, a script that classifies an unfamiliar method by guessing is asserting something it cannot know. unknown-method prints the reference page to read instead, which is a smaller answer and a true one.
A probe that comes back callable is evidence about this token in this organization, and nothing more. The same method may be barred for a different token class, and the script says so rather than issuing a clean bill of health for the whole customer base.
The fix, as a flow
The red arrow is the second, which is early, and that is the point: the refusal arrives on the first attempt and never moves. What follows it is not an outage, it is a week. The natural reading of a refusal is that something is missing, so scopes get added, an install gets repeated, an org owner gets asked to try it themselves, and none of it changes anything, because the refusal is attached to the method rather than to the credential or the person. The fix branch sorts methods, and it exists mostly to keep four different Grid refusals apart. Only the first row is this note. The fourth is the read only rule: a write in your call list is named and mapped and never issued, because the only way to ask a write method whether it is barred is to perform it in somebody else's organization.
How to fix it
Get the method list out of your source, not out of your head
Grep for slack.com/api/ or for your SDK's call sites and pass the result to --methods. The point of the exercise is completeness: the method that breaks the Grid deployment is nearly always one nobody remembered was in there.
Decide, per method, whether the audit is allowed to call it
may_probe answers read, needs-argument, write-report-only or unknown. A write is reported from the map and never issued. A read that requires a channel or user id is skipped until you pass --channel or --user, because a probe that fails on a missing argument tells you nothing about Enterprise.
Classify the refusal rather than the failure
refusal_kind separates the four Grid refusals that arrive in the same field. Only barred-on-enterprise is this note. migration clears by itself, wrong-workspace is a token-reach problem, and token-class and scope are credential problems with their own repairs.
Look up the organization-level equivalent, or the absence of one
org_equivalent answers has-equivalent with the replacement method, its admin.*:read scope and the fact that it needs a user token; no-equivalent, which means per-workspace iteration; already-admin; or unknown-method.
Read the surface, not the individual rows
surface_summary answers clean, partially-barred, barred or not-assessed. Three barred methods out of forty is a substitution job. Forty out of forty means the token class itself is wrong and you should read the row that says token-class first.
Get the admin user token before you change any calls
repair_plan prints the credential work first because it is the long pole: an admin.*:read scope is a user token scope, the install has to be performed by an org owner or admin, and the resulting token is stored separately from the bot token and used only for admin.* calls.
How to check it worked
Once the substitutions ship and the admin token is stored, re-run with the same method list against the same organization. Every row should read callable or write-report-only.
python3 slack_enterprise_method_audit.py \
--methods conversations.list,users.list,conversations.history,chat.postMessage
# context enterprise enterprise_id E04NORTHWIND, so a refusal here may be
# about the Enterprise rather than about your token
# method conversations.list barred-on-enterprise the method cannot be called
# from an Enterprise
# -> admin.conversations.search needs admin.conversations:read on an
# admin user token
# method users.list barred-on-enterprise the method cannot be called
# from an Enterprise
# -> admin.users.list needs admin.users:read on an admin user token
# method conversations.history needs-argument pass --channel to probe it
# method chat.postMessage write-report-only named, mapped, never called;
# no org-level equivalent, so keep a workspace-scoped
# token for it
# surface partially-barred 2 barred, 1 callable, 1 not probed, of 4
# verdict 3 finding(s)
# repair: request admin.conversations:read and admin.users:read as USER token
# scopes and have an org owner or admin perform the installation
# repair: store the resulting user token separately and use it only for admin.*
# repair: for methods with no org-level equivalent, iterate per workspace with a
# workspace-scoped token
The full code
Four pure functions and one probe loop. refusal_kind is the one that earns the note: four different Grid refusals arrive in the same error field and only one of them is this problem, so the script never reports “Grid failure”. may_probe is the read-only guard, and it is a map rather than a heuristic, because guessing that a method is safe to call in somebody else's organization is not a guess worth making.
"""Find which of your Slack methods are barred in an Enterprise organization.
Read only. Every probe is a GET, and only methods the map marks as reads are
issued at all. Writes are named, mapped to their organization-level equivalent
where one exists, and never called: there is no way to ask a write method
whether it is barred except by performing it, and this runs against a
customer's production organization.
enterprise_is_restricted is documented as "The method cannot be called from an
Enterprise." Slack does not publish the set of methods that can return it, so
this script reports what it observed plus what the map knows, and says unknown
for anything else.
"""
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_enterprise_method_audit")
API = "https://slack.com/api/"
# Reads that answer with no arguments at all, so a probe is meaningful.
ARGUMENT_FREE_READS = ("auth.test", "conversations.list", "users.list", "team.info",
"emoji.list", "usergroups.list", "users.conversations",
"files.list", "team.profile.get", "team.billableInfo",
"chat.scheduledMessages.list", "admin.teams.list",
"admin.users.list", "admin.conversations.search")
# Reads that need a channel, user or message to mean anything. Probed only when
# the argument is supplied, because a missing-argument error says nothing about
# Enterprise.
ARGUMENT_READS = ("conversations.info", "conversations.history",
"conversations.members", "conversations.replies", "users.info",
"bots.info", "reactions.get", "pins.list", "files.info")
# Writes. Named, mapped, never issued.
WRITE_METHODS = ("chat.postMessage", "chat.postEphemeral", "chat.update",
"chat.delete", "chat.scheduleMessage", "conversations.create",
"conversations.invite", "conversations.join", "conversations.archive",
"conversations.rename", "views.publish", "views.open",
"usergroups.create", "usergroups.update", "usergroups.disable",
"usergroups.enable", "files.upload", "files.completeUploadExternal",
"reactions.add", "pins.add")
# The substitutions that are genuinely equivalent. Everything absent from this
# map is reported as having no equivalent, which means "iterate per workspace",
# because inventing a plausible admin.* replacement is worse than saying nothing.
ORG_EQUIVALENTS = {
"conversations.list": ("admin.conversations.search", "admin.conversations:read"),
"users.list": ("admin.users.list", "admin.users:read"),
"team.info": ("admin.teams.list", "admin.teams:read"),
}
MIGRATION_ERRORS = ("org_login_required", "team_added_to_org")
TRANSIENT_ERRORS = ("ratelimited", "service_unavailable", "internal_error",
"fatal_error", "request_timeout", "unparseable_body")
def may_probe(method):
"""Is this audit allowed to call this method? Pure.
Returns (state, detail).
read the method is argument-free and safe to issue.
needs-argument a read that needs a channel, user or message id.
write-report-only a write. Named and mapped; never called.
unknown not in any list; reported rather than guessed at.
"""
name = str(method or "").strip()
if name in WRITE_METHODS:
return ("write-report-only", "this method writes, so it is named and mapped "
"and never issued by an audit")
if name in ARGUMENT_FREE_READS:
return ("read", "an argument-free read, so a probe is meaningful on its own")
if name in ARGUMENT_READS:
return ("needs-argument", "a read that needs a channel, user or message id; "
"pass one or this method is skipped")
return ("unknown", "not in the map this script carries; check the method's own "
"reference page for enterprise_is_restricted in its errors "
"table")
def refusal_kind(body):
"""Which of the Grid refusals is this? Pure.
Four different refusals arrive in the same error field and only one of them
is this note. Returns (state, detail).
callable ok: true.
barred-on-enterprise enterprise_is_restricted. This note.
wrong-workspace team_access_not_granted: the token has a boundary.
migration org_login_required or team_added_to_org: wait.
token-class not_allowed_token_type: the wrong kind of credential.
scope missing_scope: a grant, not a context.
admin-role not_an_admin: the right token, the wrong person.
plan feature_not_enabled: not an Enterprise at all.
argument the probe was malformed or under-specified.
transient retry later; nothing was learned.
other handed on rather than absorbed.
"""
doc = body or {}
if doc.get("ok") is True:
return ("callable", "the method answered ok: true for this token in this "
"organization")
error = str(doc.get("error") or "")
if error == "enterprise_is_restricted":
return ("barred-on-enterprise", "enterprise_is_restricted: the method cannot "
"be called from an Enterprise")
if error == "team_access_not_granted":
return ("wrong-workspace", "team_access_not_granted: the token is bounded to "
"one workspace, which is a reach problem rather "
"than a method problem")
if error in MIGRATION_ERRORS:
return ("migration", "%s: the workspace is migrating into an organization and "
"will answer once it settles" % error)
if error == "not_allowed_token_type":
return ("token-class", "not_allowed_token_type: the credential is the wrong "
"class for this method, most often a bot token where a "
"user token is required")
if error == "missing_scope":
return ("scope", "missing_scope: a grant is absent, which is a different "
"repair from a method being barred")
if error == "not_an_admin":
return ("admin-role", "not_an_admin: the token class is right and the person "
"behind it lacks the role")
if error == "feature_not_enabled":
return ("plan", "feature_not_enabled: the admin surface is an Enterprise "
"feature and this customer is not on it")
if error in ("invalid_arguments", "invalid_arg_name", "channel_not_found",
"user_not_found", "not_in_channel"):
return ("argument", "%s: the probe was under-specified, so nothing was "
"learned about Enterprise" % error)
if error in TRANSIENT_ERRORS:
return ("transient", "%s: try again later; this probe established nothing"
% error)
if not error:
return ("other", "no ok field and no error, so nothing can be read from this "
"response")
return ("other", "%s, which is a different problem" % error)
def org_equivalent(method):
"""Is there an organization-level replacement for this method? Pure.
Returns (state, detail).
has-equivalent the replacement, its scope, and the token class it needs.
already-admin the method is already an admin.* method.
no-equivalent none exists; iterate per workspace with a workspace token.
unknown-method not in this script's map at all.
"""
name = str(method or "").strip()
if name.startswith("admin."):
return ("already-admin", "this is an organization-level method; it needs an "
"admin.*:read scope on an admin user token")
if name in ORG_EQUIVALENTS:
replacement, scope = ORG_EQUIVALENTS[name]
return ("has-equivalent", "%s, which needs %s on an admin user token held by "
"an org owner or admin" % (replacement, scope))
known = (name in ARGUMENT_FREE_READS or name in ARGUMENT_READS
or name in WRITE_METHODS)
if known:
return ("no-equivalent", "no organization-level equivalent; keep a "
"workspace-scoped token and iterate per workspace")
return ("unknown-method", "not in this script's map; read the method's own "
"reference page rather than assuming a replacement")
def surface_summary(rows):
"""One sentence about the whole call surface. Pure.
rows: [(method, kind), ...] where kind came from refusal_kind or may_probe.
Returns (state, counts).
"""
seen = [(str(m), str(k)) for m, k in (rows or [])]
counts = {"methods": len(seen), "barred": 0, "callable": 0, "not_probed": 0,
"other": 0}
for _method, kind in seen:
if kind == "barred-on-enterprise":
counts["barred"] += 1
elif kind == "callable":
counts["callable"] += 1
elif kind in ("write-report-only", "needs-argument", "unknown", "transient"):
counts["not_probed"] += 1
else:
counts["other"] += 1
if not seen:
return ("nothing-probed", counts)
if not counts["barred"] and not counts["callable"]:
return ("not-assessed", counts)
if not counts["barred"]:
return ("clean", counts)
if not counts["callable"]:
return ("barred", counts)
return ("partially-barred", counts)
def repair_plan(rows):
"""The credential work first, because it is the long pole. Pure.
rows: [(method, kind, equivalent_state, equivalent_detail), ...].
"""
scopes, per_workspace, other = [], [], []
for method, kind, eq_state, eq_detail in (rows or []):
if kind not in ("barred-on-enterprise", "token-class"):
continue
if eq_state == "has-equivalent":
scopes.append("%s -> %s" % (method, eq_detail))
elif eq_state in ("no-equivalent", "already-admin"):
per_workspace.append(method)
else:
other.append(method)
out = []
if scopes:
out.append("request the admin.*:read scopes below as USER token scopes and "
"have an org owner or admin perform the installation")
out.extend(" " + line for line in scopes)
out.append("store the resulting user token separately from the bot token and "
"use it only for admin.* calls")
if per_workspace:
out.append("no organization-level equivalent for %s; iterate per workspace "
"with a workspace-scoped token" % ", ".join(per_workspace))
if other:
out.append("read the reference page for %s; this script will not guess a "
"replacement" % ", ".join(other))
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 your app uses")
ap.add_argument("--methods", default="",
help="comma separated Web API methods your code calls")
ap.add_argument("--channel", default="",
help="a channel id, so channel-scoped reads can be probed")
ap.add_argument("--user", default="",
help="a user id, so user-scoped reads can be probed")
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("token missing set %s to the token your app uses",
args.token_env)
return 2
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + token})
auth = get(session, "auth.test")
if auth.get("ok") is not True:
log.error("context unreadable auth.test answered %s",
auth.get("error") or "nothing")
return 2
enterprise = auth.get("enterprise_id") or ""
if enterprise:
log.info("context enterprise enterprise_id %s, so a refusal here "
"may be about the Enterprise rather than about your token",
enterprise)
else:
log.info("context standalone no enterprise_id, so "
"enterprise_is_restricted cannot arise for this installation")
rows, repair_rows = [], []
for method in [m.strip() for m in args.methods.split(",") if m.strip()]:
safety, why = may_probe(method)
if safety == "read":
kind, detail = refusal_kind(get(session, method, {"limit": 1}))
elif safety == "needs-argument" and (args.channel or args.user):
params = {"limit": 1}
if args.channel:
params["channel"] = args.channel
if args.user:
params["user"] = args.user
kind, detail = refusal_kind(get(session, method, params))
else:
kind, detail = safety, why
eq_state, eq_detail = org_equivalent(method)
rows.append((method, kind))
repair_rows.append((method, kind, eq_state, eq_detail))
(log.info if kind in ("callable", "write-report-only") else log.warning)(
"method %-22s %-22s %s", method, kind, detail)
if kind in ("barred-on-enterprise", "token-class"):
log.warning(" -> %-19s %s", eq_state, eq_detail)
summary, counts = surface_summary(rows)
(log.info if summary in ("clean", "nothing-probed") else log.warning)(
"surface %-18s %d barred, %d callable, %d not probed, of %d",
summary, counts["barred"], counts["callable"], counts["not_probed"],
counts["methods"])
repairs = repair_plan(repair_rows)
if not repairs:
log.info("verdict clean no method in the list is barred for "
"this token in this organization")
return 0
log.warning("verdict %d finding(s)", len(repairs))
for line in repairs:
log.warning(" repair: %s", line)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Find which of your Slack methods are barred in an Enterprise organization.
*
* Read only. Every probe is a GET, and only methods the map marks as reads are
* issued at all. Writes are named, mapped to their organization-level
* equivalent where one exists, and never called.
*
* enterprise_is_restricted is documented as "The method cannot be called from
* an Enterprise." Slack does not publish the set of methods that can return it,
* so this script reports what it observed plus what the map knows.
*/
const API = 'https://slack.com/api/';
// Reads that answer with no arguments at all.
export const ARGUMENT_FREE_READS = ['auth.test', 'conversations.list', 'users.list',
'team.info', 'emoji.list', 'usergroups.list', 'users.conversations', 'files.list',
'team.profile.get', 'team.billableInfo', 'chat.scheduledMessages.list',
'admin.teams.list', 'admin.users.list', 'admin.conversations.search'];
// Reads that need a channel, user or message to mean anything.
export const ARGUMENT_READS = ['conversations.info', 'conversations.history',
'conversations.members', 'conversations.replies', 'users.info', 'bots.info',
'reactions.get', 'pins.list', 'files.info'];
// Writes. Named, mapped, never issued.
export const WRITE_METHODS = ['chat.postMessage', 'chat.postEphemeral', 'chat.update',
'chat.delete', 'chat.scheduleMessage', 'conversations.create',
'conversations.invite', 'conversations.join', 'conversations.archive',
'conversations.rename', 'views.publish', 'views.open', 'usergroups.create',
'usergroups.update', 'usergroups.disable', 'usergroups.enable', 'files.upload',
'files.completeUploadExternal', 'reactions.add', 'pins.add'];
// Only substitutions that are genuinely equivalent.
export const ORG_EQUIVALENTS = {
'conversations.list': ['admin.conversations.search', 'admin.conversations:read'],
'users.list': ['admin.users.list', 'admin.users:read'],
'team.info': ['admin.teams.list', 'admin.teams:read'],
};
export const MIGRATION_ERRORS = ['org_login_required', 'team_added_to_org'];
export const TRANSIENT_ERRORS = ['ratelimited', 'service_unavailable',
'internal_error', 'fatal_error', 'request_timeout', 'unparseable_body'];
/**
* Is this audit allowed to call this method? Pure.
* Returns [state, detail]; read, needs-argument, write-report-only, unknown.
*/
export function mayProbe(method) {
const name = String(method ?? '').trim();
if (WRITE_METHODS.includes(name)) {
return ['write-report-only', 'this method writes, so it is named and mapped and '
+ 'never issued by an audit'];
}
if (ARGUMENT_FREE_READS.includes(name)) {
return ['read', 'an argument-free read, so a probe is meaningful on its own'];
}
if (ARGUMENT_READS.includes(name)) {
return ['needs-argument', 'a read that needs a channel, user or message id; pass '
+ 'one or this method is skipped'];
}
return ['unknown', 'not in the map this script carries; check the method\'s own '
+ 'reference page for enterprise_is_restricted in its errors table'];
}
/**
* Which of the Grid refusals is this? Pure.
* Returns [state, detail].
*/
export function refusalKind(body) {
const doc = body ?? {};
if (doc.ok === true) {
return ['callable', 'the method answered ok: true for this token in this '
+ 'organization'];
}
const error = String(doc.error ?? '');
if (error === 'enterprise_is_restricted') {
return ['barred-on-enterprise', 'enterprise_is_restricted: the method cannot be '
+ 'called from an Enterprise'];
}
if (error === 'team_access_not_granted') {
return ['wrong-workspace', 'team_access_not_granted: the token is bounded to one '
+ 'workspace, which is a reach problem rather than a method problem'];
}
if (MIGRATION_ERRORS.includes(error)) {
return ['migration', `${error}: the workspace is migrating into an organization `
+ 'and will answer once it settles'];
}
if (error === 'not_allowed_token_type') {
return ['token-class', 'not_allowed_token_type: the credential is the wrong class '
+ 'for this method, most often a bot token where a user token is required'];
}
if (error === 'missing_scope') {
return ['scope', 'missing_scope: a grant is absent, which is a different repair '
+ 'from a method being barred'];
}
if (error === 'not_an_admin') {
return ['admin-role', 'not_an_admin: the token class is right and the person '
+ 'behind it lacks the role'];
}
if (error === 'feature_not_enabled') {
return ['plan', 'feature_not_enabled: the admin surface is an Enterprise feature '
+ 'and this customer is not on it'];
}
if (['invalid_arguments', 'invalid_arg_name', 'channel_not_found', 'user_not_found',
'not_in_channel'].includes(error)) {
return ['argument', `${error}: the probe was under-specified, so nothing was `
+ 'learned about Enterprise'];
}
if (TRANSIENT_ERRORS.includes(error)) {
return ['transient', `${error}: try again later; this probe established nothing`];
}
if (!error) {
return ['other', 'no ok field and no error, so nothing can be read from this '
+ 'response'];
}
return ['other', `${error}, which is a different problem`];
}
/**
* Is there an organization-level replacement for this method? Pure.
* Returns [state, detail]; has-equivalent, already-admin, no-equivalent,
* unknown-method.
*/
export function orgEquivalent(method) {
const name = String(method ?? '').trim();
if (name.startsWith('admin.')) {
return ['already-admin', 'this is an organization-level method; it needs an '
+ 'admin.*:read scope on an admin user token'];
}
if (Object.prototype.hasOwnProperty.call(ORG_EQUIVALENTS, name)) {
const [replacement, scope] = ORG_EQUIVALENTS[name];
return ['has-equivalent', `${replacement}, which needs ${scope} on an admin user `
+ 'token held by an org owner or admin'];
}
const known = ARGUMENT_FREE_READS.includes(name) || ARGUMENT_READS.includes(name)
|| WRITE_METHODS.includes(name);
if (known) {
return ['no-equivalent', 'no organization-level equivalent; keep a '
+ 'workspace-scoped token and iterate per workspace'];
}
return ['unknown-method', 'not in this script\'s map; read the method\'s own '
+ 'reference page rather than assuming a replacement'];
}
/**
* One sentence about the whole call surface. Pure.
* Returns [state, counts].
*/
export function surfaceSummary(rows) {
const seen = (rows ?? []).map(([m, k]) => [String(m), String(k)]);
const counts = {
methods: seen.length, barred: 0, callable: 0, not_probed: 0, other: 0,
};
for (const [, kind] of seen) {
if (kind === 'barred-on-enterprise') counts.barred += 1;
else if (kind === 'callable') counts.callable += 1;
else if (['write-report-only', 'needs-argument', 'unknown',
'transient'].includes(kind)) counts.not_probed += 1;
else counts.other += 1;
}
if (!seen.length) return ['nothing-probed', counts];
if (!counts.barred && !counts.callable) return ['not-assessed', counts];
if (!counts.barred) return ['clean', counts];
if (!counts.callable) return ['barred', counts];
return ['partially-barred', counts];
}
/** The credential work first, because it is the long pole. Pure. */
export function repairPlan(rows) {
const scopes = [];
const perWorkspace = [];
const other = [];
for (const [method, kind, eqState, eqDetail] of (rows ?? [])) {
if (kind !== 'barred-on-enterprise' && kind !== 'token-class') continue;
if (eqState === 'has-equivalent') scopes.push(`${method} -> ${eqDetail}`);
else if (eqState === 'no-equivalent' || eqState === 'already-admin') {
perWorkspace.push(method);
} else other.push(method);
}
const out = [];
if (scopes.length) {
out.push('request the admin.*:read scopes below as USER token scopes and have an '
+ 'org owner or admin perform the installation');
for (const line of scopes) out.push(` ${line}`);
out.push('store the resulting user token separately from the bot token and use it '
+ 'only for admin.* calls');
}
if (perWorkspace.length) {
out.push(`no organization-level equivalent for ${perWorkspace.join(', ')}; `
+ 'iterate per workspace with a workspace-scoped token');
}
if (other.length) {
out.push(`read the reference page for ${other.join(', ')}; this script will not `
+ 'guess a replacement');
}
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 your `
+ 'app uses');
process.exitCode = 2;
return;
}
const auth = await read(token, 'auth.test', {});
if (auth.ok !== true) {
console.error(`context unreadable auth.test answered `
+ `${auth.error ?? 'nothing'}`);
process.exitCode = 2;
return;
}
const enterprise = auth.enterprise_id ?? '';
if (enterprise) {
console.log(`context enterprise enterprise_id ${enterprise}, so a `
+ 'refusal here may be about the Enterprise rather than about your token');
} else {
console.log('context standalone no enterprise_id, so '
+ 'enterprise_is_restricted cannot arise for this installation');
}
const channel = arg(args, '--channel');
const user = arg(args, '--user');
const rows = [];
const repairRows = [];
for (const method of arg(args, '--methods').split(',').map((m) => m.trim())
.filter(Boolean)) {
const [safety, why] = mayProbe(method);
let kind = safety;
let detail = why;
if (safety === 'read') {
// eslint-disable-next-line no-await-in-loop
[kind, detail] = refusalKind(await read(token, method, { limit: 1 }));
} else if (safety === 'needs-argument' && (channel || user)) {
const params = { limit: 1 };
if (channel) params.channel = channel;
if (user) params.user = user;
// eslint-disable-next-line no-await-in-loop
[kind, detail] = refusalKind(await read(token, method, params));
}
const [eqState, eqDetail] = orgEquivalent(method);
rows.push([method, kind]);
repairRows.push([method, kind, eqState, eqDetail]);
const line = `method ${method.padEnd(22)} ${kind.padEnd(22)} ${detail}`;
if (kind === 'callable' || kind === 'write-report-only') console.log(line);
else console.warn(line);
if (kind === 'barred-on-enterprise' || kind === 'token-class') {
console.warn(` -> ${eqState.padEnd(19)} ${eqDetail}`);
}
}
const [summary, counts] = surfaceSummary(rows);
const summaryLine = `surface ${summary.padEnd(18)} ${counts.barred} barred, `
+ `${counts.callable} callable, ${counts.not_probed} not probed, of `
+ `${counts.methods}`;
if (summary === 'clean' || summary === 'nothing-probed') console.log(summaryLine);
else console.warn(summaryLine);
const repairs = repairPlan(repairRows);
if (!repairs.length) {
console.log('verdict clean no method in the list is barred for '
+ 'this token in this organization');
return;
}
console.warn(`verdict ${repairs.length} finding(s)`);
for (const line of repairs) console.warn(` repair: ${line}`);
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 response bodies with one field in them, which is all these refusals ever amount to. The assertions worth reading are the block around refusal_kind: five different errors that a reader might file under “Grid problem” must come back as five different states, because each one has a different repair and three of them are not this note. The other assertion that matters is negative: every write in the map must classify as write-report-only, and no test anywhere calls one.
from slack_enterprise_method_audit import (
may_probe, org_equivalent, refusal_kind, repair_plan, surface_summary,
)
def test_an_argument_free_read_may_be_probed():
state, detail = may_probe("conversations.list")
assert state == "read"
assert "meaningful" in detail
def test_a_write_is_named_and_never_issued():
state, detail = may_probe("chat.postMessage")
assert state == "write-report-only"
assert "never issued" in detail
def test_every_write_in_the_map_is_report_only():
from slack_enterprise_method_audit import WRITE_METHODS
assert all(may_probe(m)[0] == "write-report-only" for m in WRITE_METHODS)
def test_a_read_needing_a_channel_is_skipped_until_one_is_supplied():
assert may_probe("conversations.history")[0] == "needs-argument"
def test_a_method_outside_the_map_is_not_guessed_at():
state, detail = may_probe("some.newMethod")
assert state == "unknown"
assert "reference page" in detail
def test_the_barred_error_is_this_note():
state, detail = refusal_kind({"ok": False, "error": "enterprise_is_restricted"})
assert state == "barred-on-enterprise"
assert "cannot be called from an Enterprise" in detail
def test_a_workspace_boundary_is_a_different_note():
state, detail = refusal_kind({"ok": False, "error": "team_access_not_granted"})
assert state == "wrong-workspace"
assert "reach problem" in detail
def test_both_migration_errors_land_in_one_state_that_says_wait():
for error in ("org_login_required", "team_added_to_org"):
state, detail = refusal_kind({"ok": False, "error": error})
assert state == "migration"
assert "settles" in detail
def test_the_wrong_credential_class_is_not_a_barred_method():
assert refusal_kind({"ok": False,
"error": "not_allowed_token_type"})[0] == "token-class"
def test_a_missing_scope_is_a_grant_not_a_context():
state, detail = refusal_kind({"ok": False, "error": "missing_scope"})
assert state == "scope"
assert "different repair" in detail
def test_the_role_and_the_plan_are_separated_from_each_other():
assert refusal_kind({"ok": False, "error": "not_an_admin"})[0] == "admin-role"
assert refusal_kind({"ok": False, "error": "feature_not_enabled"})[0] == "plan"
def test_an_under_specified_probe_learns_nothing_and_says_so():
state, detail = refusal_kind({"ok": False, "error": "channel_not_found"})
assert state == "argument"
assert "nothing was learned" in detail
def test_a_rate_limit_establishes_nothing():
assert refusal_kind({"ok": False, "error": "ratelimited"})[0] == "transient"
def test_a_successful_call_is_evidence_about_this_token_only():
state, detail = refusal_kind({"ok": True})
assert state == "callable"
assert "this token" in detail
def test_an_empty_body_is_handed_on_rather_than_absorbed():
assert refusal_kind({})[0] == "other"
def test_the_three_substitutions_carry_their_scope_and_token_class():
for method, replacement in (("conversations.list", "admin.conversations.search"),
("users.list", "admin.users.list"),
("team.info", "admin.teams.list")):
state, detail = org_equivalent(method)
assert state == "has-equivalent"
assert replacement in detail
assert "user token" in detail
def test_an_admin_method_is_already_at_the_organization_level():
assert org_equivalent("admin.conversations.search")[0] == "already-admin"
def test_a_known_method_with_no_replacement_says_iterate_per_workspace():
state, detail = org_equivalent("chat.postMessage")
assert state == "no-equivalent"
assert "per workspace" in detail
def test_an_unknown_method_gets_no_invented_replacement():
state, detail = org_equivalent("some.newMethod")
assert state == "unknown-method"
assert "assuming a replacement" in detail
def test_a_surface_with_no_barred_methods_is_clean():
state, counts = surface_summary([("conversations.list", "callable"),
("users.list", "callable")])
assert state == "clean"
assert counts["callable"] == 2
def test_a_mixed_surface_is_partially_barred():
state, counts = surface_summary([("conversations.list", "barred-on-enterprise"),
("users.list", "callable"),
("chat.postMessage", "write-report-only")])
assert state == "partially-barred"
assert counts["barred"] == 1
assert counts["not_probed"] == 1
def test_a_wholly_barred_surface_points_at_the_token_class():
assert surface_summary([("conversations.list", "barred-on-enterprise"),
("users.list", "barred-on-enterprise")])[0] == "barred"
def test_a_surface_of_only_unprobed_methods_concludes_nothing():
assert surface_summary([("chat.postMessage", "write-report-only")])[0] == \
"not-assessed"
def test_probing_nothing_says_so():
assert surface_summary([])[0] == "nothing-probed"
def test_the_repair_puts_the_credential_work_first():
lines = repair_plan([("conversations.list", "barred-on-enterprise",
"has-equivalent", "admin.conversations.search, which needs "
"admin.conversations:read on an admin "
"user token")])
assert "USER token scopes" in lines[0]
assert any("admin.conversations.search" in line for line in lines)
assert any("separately from the bot token" in line for line in lines)
def test_a_barred_method_with_no_equivalent_gets_the_per_workspace_line():
lines = repair_plan([("chat.postMessage", "barred-on-enterprise",
"no-equivalent", "none")])
assert any("iterate per workspace" in line for line in lines)
def test_an_unknown_barred_method_is_handed_to_the_reference_page():
lines = repair_plan([("some.newMethod", "barred-on-enterprise",
"unknown-method", "none")])
assert any("will not guess" in line for line in lines)
def test_a_callable_surface_needs_no_repair():
assert repair_plan([("conversations.list", "callable", "has-equivalent",
"x")]) == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
WRITE_METHODS, mayProbe, orgEquivalent, refusalKind, repairPlan, surfaceSummary,
} from './slack-enterprise-method-audit.mjs';
test('an argument free read may be probed', () => {
const [state, detail] = mayProbe('conversations.list');
assert.equal(state, 'read');
assert.match(detail, /meaningful/);
});
test('a write is named and never issued', () => {
const [state, detail] = mayProbe('chat.postMessage');
assert.equal(state, 'write-report-only');
assert.match(detail, /never issued/);
});
test('every write in the map is report only', () => {
assert.equal(WRITE_METHODS.every((m) => mayProbe(m)[0] === 'write-report-only'),
true);
});
test('a read needing a channel is skipped until one is supplied', () => {
assert.equal(mayProbe('conversations.history')[0], 'needs-argument');
});
test('a method outside the map is not guessed at', () => {
const [state, detail] = mayProbe('some.newMethod');
assert.equal(state, 'unknown');
assert.match(detail, /reference page/);
});
test('the barred error is this note', () => {
const [state, detail] = refusalKind({
ok: false, error: 'enterprise_is_restricted',
});
assert.equal(state, 'barred-on-enterprise');
assert.match(detail, /cannot be called from an Enterprise/);
});
test('a workspace boundary is a different note', () => {
const [state, detail] = refusalKind({
ok: false, error: 'team_access_not_granted',
});
assert.equal(state, 'wrong-workspace');
assert.match(detail, /reach problem/);
});
test('both migration errors land in one state that says wait', () => {
for (const error of ['org_login_required', 'team_added_to_org']) {
const [state, detail] = refusalKind({ ok: false, error });
assert.equal(state, 'migration');
assert.match(detail, /settles/);
}
});
test('the wrong credential class is not a barred method', () => {
assert.equal(refusalKind({ ok: false, error: 'not_allowed_token_type' })[0],
'token-class');
});
test('a missing scope is a grant not a context', () => {
const [state, detail] = refusalKind({ ok: false, error: 'missing_scope' });
assert.equal(state, 'scope');
assert.match(detail, /different repair/);
});
test('the role and the plan are separated from each other', () => {
assert.equal(refusalKind({ ok: false, error: 'not_an_admin' })[0], 'admin-role');
assert.equal(refusalKind({ ok: false, error: 'feature_not_enabled' })[0], 'plan');
});
test('an under specified probe learns nothing and says so', () => {
const [state, detail] = refusalKind({ ok: false, error: 'channel_not_found' });
assert.equal(state, 'argument');
assert.match(detail, /nothing was learned/);
});
test('a rate limit establishes nothing', () => {
assert.equal(refusalKind({ ok: false, error: 'ratelimited' })[0], 'transient');
});
test('a successful call is evidence about this token only', () => {
const [state, detail] = refusalKind({ ok: true });
assert.equal(state, 'callable');
assert.match(detail, /this token/);
});
test('an empty body is handed on rather than absorbed', () => {
assert.equal(refusalKind({})[0], 'other');
});
test('the three substitutions carry their scope and token class', () => {
const pairs = [['conversations.list', 'admin.conversations.search'],
['users.list', 'admin.users.list'], ['team.info', 'admin.teams.list']];
for (const [method, replacement] of pairs) {
const [state, detail] = orgEquivalent(method);
assert.equal(state, 'has-equivalent');
assert.equal(detail.includes(replacement), true);
assert.match(detail, /user token/);
}
});
test('an admin method is already at the organization level', () => {
assert.equal(orgEquivalent('admin.conversations.search')[0], 'already-admin');
});
test('a known method with no replacement says iterate per workspace', () => {
const [state, detail] = orgEquivalent('chat.postMessage');
assert.equal(state, 'no-equivalent');
assert.match(detail, /per workspace/);
});
test('an unknown method gets no invented replacement', () => {
const [state, detail] = orgEquivalent('some.newMethod');
assert.equal(state, 'unknown-method');
assert.match(detail, /assuming a replacement/);
});
test('a surface with no barred methods is clean', () => {
const [state, counts] = surfaceSummary([['conversations.list', 'callable'],
['users.list', 'callable']]);
assert.equal(state, 'clean');
assert.equal(counts.callable, 2);
});
test('a mixed surface is partially barred', () => {
const [state, counts] = surfaceSummary([
['conversations.list', 'barred-on-enterprise'],
['users.list', 'callable'],
['chat.postMessage', 'write-report-only'],
]);
assert.equal(state, 'partially-barred');
assert.equal(counts.barred, 1);
assert.equal(counts.not_probed, 1);
});
test('a wholly barred surface points at the token class', () => {
assert.equal(surfaceSummary([['conversations.list', 'barred-on-enterprise'],
['users.list', 'barred-on-enterprise']])[0], 'barred');
});
test('a surface of only unprobed methods concludes nothing', () => {
assert.equal(surfaceSummary([['chat.postMessage', 'write-report-only']])[0],
'not-assessed');
});
test('probing nothing says so', () => {
assert.equal(surfaceSummary([])[0], 'nothing-probed');
});
test('the repair puts the credential work first', () => {
const lines = repairPlan([['conversations.list', 'barred-on-enterprise',
'has-equivalent',
'admin.conversations.search, which needs admin.conversations:read on an admin '
+ 'user token']]);
assert.match(lines[0], /USER token scopes/);
assert.equal(lines.some((l) => l.includes('admin.conversations.search')), true);
assert.equal(lines.some((l) => l.includes('separately from the bot token')), true);
});
test('a barred method with no equivalent gets the per workspace line', () => {
const lines = repairPlan([['chat.postMessage', 'barred-on-enterprise',
'no-equivalent', 'none']]);
assert.equal(lines.some((l) => l.includes('iterate per workspace')), true);
});
test('an unknown barred method is handed to the reference page', () => {
const lines = repairPlan([['some.newMethod', 'barred-on-enterprise',
'unknown-method', 'none']]);
assert.equal(lines.some((l) => l.includes('will not guess')), true);
});
test('a callable surface needs no repair', () => {
assert.deepEqual(repairPlan([['conversations.list', 'callable', 'has-equivalent',
'x']]), []);
});
FAQ
Which methods return enterprise_is_restricted? Is there a list?
Not a published one, and that is the honest answer rather than a gap in this note. The error appears in the errors table of individual method reference pages, so you can confirm it for a method you already suspect but you cannot enumerate the set from the documentation. That is why the script probes the methods you actually call rather than trying to be a catalogue, and why a method it has never heard of is reported as unknown with a pointer to its reference page instead of being classified.
An admin blocked our app for one user and we get app_access_restricted. Is that the same thing?
No. That refusal is about a person: an administrator has decided this app may be used by some members and not others, and the same call succeeds for everybody else in the same workspace with the same token. enterprise_is_restricted is about the method: it fails for every user, because the method itself is not callable in this context. The quickest way to tell them apart in a log is to group the failures by user id. If they cluster into a cohort, it is the per-user restriction. If they cluster by method name, it is this.
We are an org owner with every scope. Why does adding more scopes not help?
Because the refusal is not a scope check. A scope says what a token is permitted to do; this error says the method is not available in this context at all, so there is no grant that would satisfy it. Adding scopes to a barred method costs you a re-install and changes nothing. The productive move is the substitution: find whether an organization-level equivalent exists, request that method's admin.*:read scope as a user token scope, and have an org owner install it.
Why does the script refuse to probe chat.postMessage?
Because probing it posts a message. There is no read-only way to ask a write method whether it works, and the audit is running against a customer's production organization, often during an evaluation. So every write in the map is classified from the map, printed with whatever equivalent exists, and never issued. The cost is that a write is reported as write-report-only rather than as callable or barred; the benefit is that the audit does not leave channels, messages or invitations behind it.
Our probe came back callable for every method. Are we safe on Grid?
Safe for that token, in that organization, on that day. The script reports exactly that and no more. A different customer may be on a different plan, a different token class may be refused where yours was not, and the write methods in your list were never probed at all. Treat a clean run as the removal of one hypothesis rather than as a clearance, and keep the error handling for enterprise_is_restricted in place regardless, because the first time you meet it will be in production at a customer you have not audited.
Related field notes
- the refusal that is about a person rather than a method
- the credential-class refusal it is most often confused with
- a third organization-level refusal, this one about the network
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.
- conversations.history method reference - Slack Docs
- chat.postMessage method reference - Slack Docs
- admin.users.list method reference - Slack Docs
- Enterprise organizations - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.