Diagnostic Slack
Approved in 37 workspaces and restricted in three
Installs from one customer stopped six weeks ago. Not failed — stopped. There is no error in your logs, because the request that would have produced one was never made: the person clicking Add to Slack is told by Slack that an administrator has to approve this, and whatever happens next happens entirely inside the customer's organization.
Meanwhile the app is working perfectly for the same customer's other thirty-seven workspaces, which is why nobody noticed. An Enterprise Grid organization keeps an approved list and a restricted list, both of which can be scoped per workspace, and your app is on the first one in most places and the second one in three. Nothing told you when it moved.
Two admin read methods answer this directly. admin.apps.approved.list and admin.apps.restricted.list each return the apps on that list, and each accepts either a team_id for one workspace or an enterprise_id for the organization — not both. So the finding is a map: your app_id, held against every workspace in the org, showing where it is approved, where it is restricted, and where it appears on neither list.
The third method is the one people forget. admin.apps.requests.list returns the approval requests that members have raised and no administrator has actioned, each with a date_created, the requesting user, the scopes asked for and the app. A request sitting in that queue for six weeks is not a refusal and does not appear on either list; it is a silent blocker, and it is the single most common reason installs stop without anything going wrong.
Unlisted is genuinely ambiguous and the script says so. An app on neither list may be installable or may be blocked, depending on whether that organization requires app approval at all — a setting these two methods do not report. Reporting unlisted as allowed is a guess, and this is one of the places where a confident wrong answer costs a customer conversation.
This is the app's own status. The refusal a particular person meets at runtime is a different note.
The problem in plain words
The symptom is an absence, which is why it survives so long. A restricted app does not generate traffic that fails; it generates traffic that never happens. Your error rate is flat, your dashboards are green, and the number that moved is installs per week for one customer, which nobody is watching per customer. The first signal is usually a support conversation months later about a rollout that quietly did not happen.
The per-workspace granularity is what makes it hard to reason about. It is natural to assume an app is approved or not approved for an organization, and the model is finer than that: the lists are maintained per workspace as well as org-wide, so an app genuinely can be approved for thirty-seven workspaces and restricted in three, with the three being the ones that had a security review or an incident or an administrator with a different opinion. An engineer testing against the org-level list sees "approved" and concludes the customer is fine.
Nothing notifies you when the status changes. There is no event, no webhook and no field on any response your app already reads that says an administrator moved you from one list to the other on a Tuesday afternoon. From your side the status is only ever observable by asking, which means it is only ever observed if somebody wrote the code that asks.
And the request queue is its own category of stuck. When an organization requires approval, a member who tries to install raises a request with a message and a scope list, and it goes to an administrator's queue. Nobody is refusing you. Nobody has decided anything. The request is simply sitting there behind other work, aging, in a place your app cannot see without an admin token — and the requesting member, who assumed it went through, has moved on.
Why it happens
This note owns the app's status, not a person's experience. The already-published app_access_restricted note starts from a runtime error that some users get and some do not, and resolves it into a cohort of people who share an attribute. This one starts from an app id and produces a map over workspaces, and its most common finding — a stalled approval request — produces no runtime error at all, for anybody, ever. Different input, different output, different person to send it to.
The map is per workspace because the lists are. Querying only the org level answers a narrower question than the one you have. The script enumerates the organization's workspaces with admin.teams.list, queries both lists per workspace, and reports the shape: uniform, split, or absent. A split is the finding worth having, because it names exactly which workspaces to raise with the administrator.
team_id and enterprise_id are documented as mutually exclusive, so the argument builder enforces it. Sending both is an argument error rather than a broader query, and building the parameters in one tested function keeps that out of the request loop where it would be discovered against a live org.
Unlisted is reported as unknown, never as allowed. Whether an app that is on neither list can be installed depends on whether the organization requires approval, and that setting is not returned by these methods. An audit that prints allowed there will eventually tell a customer their app is fine in a workspace where members cannot install it, which is worse than printing nothing.
The request queue is read with an age, because the age is the finding. A request raised this morning is a normal part of the process. The same request at forty-one days is a decision nobody has made, and it is actionable in a way the raw list is not: the administrator can be shown their own queue, with dates, and the name of the member of their organization who is waiting.
Everything is a read, and the write half of the family is not touched. admin.apps.approve and admin.apps.restrict exist and would change a customer's organization. This script calls the three list methods and nothing else, and the repair it prints is a sentence for an administrator rather than a call for you.
The fix, as a flow
The red arrow points at nothing happening, which is the hardest kind of failure to notice. An administrator moves the app from one list to another, or a member raises an approval request that goes into a queue, and from that moment the thing that changes is a number that was never being watched: installs, for one customer. No error is generated anywhere, because the calls that would have failed were never made. Every box after the arrow is six weeks of flat, healthy metrics. The fix branch sorts workspaces, one pair of reads each, and its fourth row is the one to be careful with: an app on neither list is not a cleared app, it is an app whose status depends on a setting these methods do not return.
How to fix it
Get an admin token, or stop here
All three methods want a user token held by an org Admin or Owner with admin.apps:read. Without one, none of this is visible — the app's listing status is simply not exposed to the app. If you do not have that token, the cohort approach is what is left.
Enumerate the organization's workspaces
admin.teams.list, paginated with cursor. The workspace count is also the denominator for coverage: a map over eight of forty workspaces is not a map of the organization, and the script says which it is.
Build the scoping argument correctly
scope_args returns the parameters for one query and refuses to set team_id and enterprise_id together, because the methods document them as mutually exclusive. Org-level and workspace-level are separate queries, and both are worth making.
Look for your own app_id on both lists
listing_status takes the two sets of app ids and returns approved, restricted, unlisted or contradictory. The last one is rare and worth surfacing rather than resolving silently.
Read the shape of the map, not just the rows
status_map turns forty rows into one sentence: uniform-approved, uniform-restricted, split, or unlisted-everywhere. A split names the workspaces, which is the part an administrator can act on.
Age the request queue
stalled_requests reads date_created against now and returns the requests older than your threshold, with the member who raised each one. Send that list, with dates, to the administrator who owns the queue.
How to check it worked
After the administrator approves the app in the affected workspaces and clears the queue, re-run. The shape should read uniform-approved and the queue should be empty.
python3 slack_app_approval_map.py --app-id A012345678 --workspaces 40
# scope organization enterprise_id=E0123, approved
# workspace T001 acme-core approved
# workspace T014 acme-security restricted
# workspace T022 acme-legal restricted
# workspace T031 acme-labs unlisted
# note neither list mentions this app; whether that permits
# installation depends on the org approval setting,
# which these methods do not report
# shape split approved in 36, restricted in 2, unlisted in 1
# request R0091 41 day(s) old, raised by U07AAAA in T014
# request R0104 12 day(s) old, raised by U09BBBB in T022
# coverage full 39 of 39 workspaces queried
# verdict 3 finding(s) 2 restricted, 1 request older than 30 days
# repair: ask the org admin to approve the app in the named workspaces
# repair: ask them to action the pending requests above; nobody has refused you
# note: nothing here was approved, restricted or requested by this script
The full code
The three list methods are ordinary paginated reads and the interest is in what is done with them. scope_args is small and tested because it encodes a documented exclusivity that is otherwise discovered against a live organization. listing_status is the per-workspace answer, and its unlisted state is deliberately not an allowed state. status_map collapses the rows into the sentence you send to an administrator, stalled_requests ages the queue because the age is the finding, and coverage keeps a partial map from reading as a complete one.
"""Map your own app's approval status across an Enterprise Grid organization.
Read only. Three list methods, all of which want a user token held by an org
Admin or Owner with admin.apps:read: admin.apps.approved.list,
admin.apps.restricted.list and admin.apps.requests.list. The write half of
that family, which would change a customer's organization, is never called.
The finding is a map rather than an error. An app can sit on the approved list
in most workspaces of an organization and on the restricted list in a few, and
nothing notifies the developer when it moves. The most common finding of all
produces no error anywhere: an approval request sitting unactioned in an
administrator's queue for six weeks.
One deliberate refusal to guess: an app on neither list is reported as
unlisted, not as allowed. Whether an unlisted app can be installed depends on
whether the organization requires app approval, and these methods do not
report that setting.
"""
import argparse
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_app_approval_map")
API = "https://slack.com/api/"
# Reads only. The approve and restrict methods in this family would change the
# customer's organization and are not called, named or accepted as arguments.
READS = ("admin.teams.list", "admin.apps.approved.list",
"admin.apps.restricted.list", "admin.apps.requests.list")
DAY = 86400.0
def scope_args(team_id=None, enterprise_id=None, limit=100, cursor=None):
"""Build the scoping parameters for one list query. Pure.
Returns (params, note) or raises ValueError. team_id and enterprise_id are
documented as mutually exclusive, and sending both is an argument error
rather than a broader query. Encoded here, with a test, rather than
discovered against a live organization.
"""
if team_id and enterprise_id:
raise ValueError("team_id and enterprise_id cannot be used together")
params = {"limit": int(limit)}
if cursor:
params["cursor"] = cursor
if team_id:
params["team_id"] = team_id
return (params, "one workspace")
if enterprise_id:
params["enterprise_id"] = enterprise_id
return (params, "the organization")
return (params, "the default scope of this token")
def app_ids(entries):
"""The app ids on one page of an approved or restricted list. Pure.
Each entry carries an app object; the id is read from there, and from a
top level app_id where a caller has already flattened the rows.
"""
out = []
for entry in entries or []:
doc = entry or {}
app = doc.get("app") or {}
found = app.get("id") or doc.get("app_id")
if found:
out.append(str(found))
return out
def listing_status(app_id, approved, restricted):
"""Where does one app stand in one scope? Pure.
Returns (state, why). "unlisted" is not "allowed": whether an unlisted app
can be installed depends on whether the organization requires approval,
which these methods do not report.
"""
wanted = str(app_id or "")
on_approved = wanted in set(approved or [])
on_restricted = wanted in set(restricted or [])
if on_approved and on_restricted:
return ("contradictory", "this app appears on both lists in this scope, which "
"is worth raising rather than resolving quietly")
if on_restricted:
return ("restricted", "an administrator has placed this app on the restricted "
"list for this scope")
if on_approved:
return ("approved", "this app is on the approved list for this scope")
return ("unlisted", "neither list mentions this app; whether that permits "
"installation depends on the org approval setting, which "
"these methods do not report")
def status_map(rows):
"""Collapse the per-workspace rows into one sentence. Pure.
rows: [(team_id, state), ...]. Returns (shape, detail, named) where named
is the workspaces an administrator has to be told about.
"""
counts = {}
for _team, state in rows or []:
counts[state] = counts.get(state, 0) + 1
total = len(rows or [])
if not total:
return ("empty", "no workspaces were queried", [])
restricted = [t for t, s in rows if s == "restricted"]
contradictory = [t for t, s in rows if s == "contradictory"]
detail = ", ".join("%s in %d" % (state, n) for state, n in sorted(counts.items()))
if counts.get("approved") == total:
return ("uniform-approved", detail, [])
if counts.get("restricted") == total:
return ("uniform-restricted", detail, restricted)
if counts.get("unlisted") == total:
return ("unlisted-everywhere", detail, [])
return ("split", detail, restricted + contradictory)
def request_age(request, now=None):
"""How long has this approval request been waiting? Pure.
Returns (days, detail). A request raised this morning is the process
working. The same request at forty-one days is a decision nobody made.
"""
doc = request or {}
created = doc.get("date_created")
if not created:
return (None, "no date_created on this request, so its age is unknown")
now = float(now if now is not None else time.time())
days = max(0.0, (now - float(created)) / DAY)
return (round(days, 1), "raised by %s in %s"
% ((doc.get("user") or {}).get("id") or "an unnamed member",
(doc.get("team") or {}).get("id") or "an unnamed workspace"))
def stalled_requests(requests, now=None, threshold_days=30):
"""The requests old enough to be a finding. Pure."""
out = []
for request in requests or []:
days, detail = request_age(request, now)
if days is not None and days >= float(threshold_days):
out.append(((request or {}).get("id"), days, detail))
return sorted(out, key=lambda row: row[1], reverse=True)
def coverage(queried, total):
"""Is this a map of the organization or a sample of it? Pure."""
queried, total = int(queried or 0), int(total or 0)
if not total:
return ("unknown", "the organization's workspace count was not established")
if queried >= total:
return ("full", "%d of %d workspaces queried" % (queried, total))
return ("partial", "%d of %d workspaces queried; the rest are unmeasured, not "
"approved" % (queried, total))
def verdict(shape, named, stalled):
"""One line for the whole map. Pure."""
findings = len(named or []) + len(stalled or [])
if shape in ("empty", "unknown"):
return ("inconclusive", "nothing was measured")
if not findings:
return ("clean", "the app is not restricted anywhere measured and no request "
"is stalled")
return ("%d finding(s)" % findings,
"%d restricted or contradictory workspace(s), %d stalled request(s)"
% (len(named or []), len(stalled or [])))
def paged(session, method, params):
"""Every page of one list method. Read only, cursor paginated."""
out, cursor = [], None
while True:
page = dict(params)
if cursor:
page["cursor"] = cursor
r = session.get(API + method, params=page, timeout=30)
try:
body = r.json()
except ValueError:
return (out, "unparseable_body")
if body.get("ok") is not True:
return (out, str(body.get("error") or "unknown_error"))
for key in ("apps", "restricted_apps", "approved_apps", "app_requests"):
if isinstance(body.get(key), list):
out.extend(body[key])
cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
if not cursor:
return (out, None)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--app-id", required=True, help="your own app id, as A0123...")
ap.add_argument("--token-env", default="SLACK_ADMIN_USER_TOKEN",
help="environment variable holding an org admin user token with "
"admin.apps:read")
ap.add_argument("--enterprise-id", default="",
help="the organization id, for the org level query")
ap.add_argument("--workspaces", type=int, default=0,
help="the organization's workspace count, if you know it, so "
"coverage can be reported")
ap.add_argument("--stale-days", type=int, default=30)
args = ap.parse_args()
token = os.environ.get(args.token_env)
if not token:
log.error("token missing set %s to an org admin user token",
args.token_env)
return 2
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + token})
if args.enterprise_id:
params, note = scope_args(enterprise_id=args.enterprise_id)
approved, err_a = paged(session, "admin.apps.approved.list", params)
restricted, err_r = paged(session, "admin.apps.restricted.list", params)
if err_a or err_r:
log.warning("scope organization unreadable: %s", err_a or err_r)
else:
state, why = listing_status(args.app_id, app_ids(approved),
app_ids(restricted))
log.info("scope organization enterprise_id=%s, %s (%s)",
args.enterprise_id, state, note)
if state != "approved":
log.warning(" note %s", why)
teams, err = paged(session, "admin.teams.list", {"limit": 100})
if err:
log.error("teams unreadable admin.teams.list: %s", err)
return 2
rows = []
for team in teams:
team_id = (team or {}).get("id")
if not team_id:
continue
params, _note = scope_args(team_id=team_id)
approved, err_a = paged(session, "admin.apps.approved.list", params)
restricted, err_r = paged(session, "admin.apps.restricted.list", params)
if err_a or err_r:
log.warning("workspace %-6s %-16s unreadable: %s", team_id,
(team or {}).get("name") or "", err_a or err_r)
continue
state, why = listing_status(args.app_id, app_ids(approved), app_ids(restricted))
level = log.info if state == "approved" else log.warning
level("workspace %-6s %-16s %s", team_id, (team or {}).get("name") or "", state)
if state == "unlisted":
log.info(" note %s", why)
rows.append((team_id, state))
shape, detail, named = status_map(rows)
(log.info if shape == "uniform-approved" else log.warning)(
"shape %-16s %s", shape, detail)
requests_out, err_q = paged(session, "admin.apps.requests.list", {"limit": 100})
stalled = [] if err_q else stalled_requests(requests_out,
threshold_days=args.stale_days)
if err_q:
log.warning("request unreadable admin.apps.requests.list: %s", err_q)
for request_id, days, who in stalled:
log.warning("request %-16s %s day(s) old, %s", request_id, days, who)
cover, cover_why = coverage(len(rows), args.workspaces or len(teams))
(log.info if cover == "full" else log.warning)("coverage %-16s %s", cover,
cover_why)
final, summary = verdict(shape, named, stalled)
if final == "clean":
log.info("verdict clean %s", summary)
log.info(" note: nothing here was approved, restricted or requested by this "
"script")
return 0
log.warning("verdict %-16s %s", final, summary)
if named:
log.warning(" repair: ask the org admin to approve the app in %s",
", ".join(named[:6]))
if stalled:
log.warning(" repair: ask them to action the pending requests above; nobody "
"has refused you, the queue simply has not been read")
log.warning(" repair: an unlisted status is not a refusal and not a clearance; "
"confirm the org's app approval setting with the administrator")
log.warning(" note: nothing here was approved, restricted or requested by this "
"script")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Map your own app's approval status across an Enterprise Grid organization.
*
* Read only. Three list methods, all wanting a user token held by an org Admin
* or Owner with admin.apps:read. The write half of that family, which would
* change a customer's organization, is never called.
*
* The finding is a map rather than an error: an app can be approved in most
* workspaces of an org and restricted in a few, and the most common finding of
* all produces no error anywhere, because it is an approval request sitting
* unactioned in an administrator's queue.
*
* One deliberate refusal to guess: an app on neither list is unlisted, not
* allowed. Whether an unlisted app can be installed depends on the org's app
* approval setting, which these methods do not report.
*/
const API = 'https://slack.com/api/';
export const READS = ['admin.teams.list', 'admin.apps.approved.list',
'admin.apps.restricted.list', 'admin.apps.requests.list'];
const DAY = 86400;
// team_id and enterprise_id are documented as mutually exclusive.
export function scopeArgs({ teamId = '', enterpriseId = '', limit = 100, cursor = '' } = {}) {
if (teamId && enterpriseId) {
throw new Error('team_id and enterprise_id cannot be used together');
}
const params = { limit: Number(limit) };
if (cursor) params.cursor = cursor;
if (teamId) {
params.team_id = teamId;
return [params, 'one workspace'];
}
if (enterpriseId) {
params.enterprise_id = enterpriseId;
return [params, 'the organization'];
}
return [params, 'the default scope of this token'];
}
export function appIds(entries) {
const out = [];
for (const entry of entries ?? []) {
const doc = entry ?? {};
const found = (doc.app ?? {}).id ?? doc.app_id;
if (found) out.push(String(found));
}
return out;
}
// "unlisted" is not "allowed".
export function listingStatus(appId, approved, restricted) {
const wanted = String(appId ?? '');
const onApproved = new Set(approved ?? []).has(wanted);
const onRestricted = new Set(restricted ?? []).has(wanted);
if (onApproved && onRestricted) {
return ['contradictory', 'this app appears on both lists in this scope, which is '
+ 'worth raising rather than resolving quietly'];
}
if (onRestricted) {
return ['restricted', 'an administrator has placed this app on the restricted list '
+ 'for this scope'];
}
if (onApproved) return ['approved', 'this app is on the approved list for this scope'];
return ['unlisted', 'neither list mentions this app; whether that permits '
+ 'installation depends on the org approval setting, which these methods do not report'];
}
export function statusMap(rows) {
const counts = {};
for (const [, state] of rows ?? []) counts[state] = (counts[state] ?? 0) + 1;
const total = (rows ?? []).length;
if (!total) return ['empty', 'no workspaces were queried', []];
const restricted = rows.filter(([, s]) => s === 'restricted').map(([t]) => t);
const contradictory = rows.filter(([, s]) => s === 'contradictory').map(([t]) => t);
const detail = Object.entries(counts).sort()
.map(([state, n]) => `${state} in ${n}`).join(', ');
if (counts.approved === total) return ['uniform-approved', detail, []];
if (counts.restricted === total) return ['uniform-restricted', detail, restricted];
if (counts.unlisted === total) return ['unlisted-everywhere', detail, []];
return ['split', detail, [...restricted, ...contradictory]];
}
export function requestAge(request, now = null) {
const doc = request ?? {};
const created = doc.date_created;
if (!created) return [null, 'no date_created on this request, so its age is unknown'];
const at = Number(now ?? Date.now() / 1000);
const days = Math.max(0, (at - Number(created)) / DAY);
const who = (doc.user ?? {}).id ?? 'an unnamed member';
const where = (doc.team ?? {}).id ?? 'an unnamed workspace';
return [Math.round(days * 10) / 10, `raised by ${who} in ${where}`];
}
export function stalledRequests(requests, now = null, thresholdDays = 30) {
const out = [];
for (const request of requests ?? []) {
const [days, detail] = requestAge(request, now);
if (days !== null && days >= Number(thresholdDays)) {
out.push([(request ?? {}).id, days, detail]);
}
}
return out.sort((a, b) => b[1] - a[1]);
}
export function coverage(queried, total) {
const q = Number(queried ?? 0);
const t = Number(total ?? 0);
if (!t) return ['unknown', "the organization's workspace count was not established"];
if (q >= t) return ['full', `${q} of ${t} workspaces queried`];
return ['partial', `${q} of ${t} workspaces queried; the rest are unmeasured, not approved`];
}
export function verdict(shape, named, stalled) {
const findings = (named ?? []).length + (stalled ?? []).length;
if (shape === 'empty' || shape === 'unknown') {
return ['inconclusive', 'nothing was measured'];
}
if (!findings) {
return ['clean', 'the app is not restricted anywhere measured and no request is stalled'];
}
return [`${findings} finding(s)`,
`${(named ?? []).length} restricted or contradictory workspace(s), `
+ `${(stalled ?? []).length} stalled request(s)`];
}
function arg(args, name, fallback = '') {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function paged(token, method, params) {
const out = [];
let cursor = '';
for (;;) {
const url = new URL(API + method);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
if (cursor) url.searchParams.set('cursor', cursor);
// eslint-disable-next-line no-await-in-loop
const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
let body;
try {
// eslint-disable-next-line no-await-in-loop
body = await r.json();
} catch {
return [out, 'unparseable_body'];
}
if (body.ok !== true) return [out, String(body.error ?? 'unknown_error')];
for (const key of ['apps', 'restricted_apps', 'approved_apps', 'app_requests']) {
if (Array.isArray(body[key])) out.push(...body[key]);
}
cursor = (body.response_metadata ?? {}).next_cursor ?? '';
if (!cursor) return [out, null];
}
}
async function main() {
const args = process.argv.slice(2);
const appId = arg(args, '--app-id');
if (!appId) {
console.error('pass --app-id with your own app id');
process.exitCode = 2;
return;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_ADMIN_USER_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`token missing set ${tokenEnv} to an org admin user token`);
process.exitCode = 2;
return;
}
const enterpriseId = arg(args, '--enterprise-id');
if (enterpriseId) {
const [params, note] = scopeArgs({ enterpriseId });
const [approved, errA] = await paged(token, 'admin.apps.approved.list', params);
const [restricted, errR] = await paged(token, 'admin.apps.restricted.list', params);
if (errA || errR) {
console.warn(`scope organization unreadable: ${errA ?? errR}`);
} else {
const [state, why] = listingStatus(appId, appIds(approved), appIds(restricted));
console.log(`scope organization enterprise_id=${enterpriseId}, `
+ `${state} (${note})`);
if (state !== 'approved') console.warn(` note ${why}`);
}
}
const [teams, err] = await paged(token, 'admin.teams.list', { limit: 100 });
if (err) {
console.error(`teams unreadable admin.teams.list: ${err}`);
process.exitCode = 2;
return;
}
const rows = [];
for (const team of teams) {
const teamId = (team ?? {}).id;
if (!teamId) continue;
const [params] = scopeArgs({ teamId });
// eslint-disable-next-line no-await-in-loop
const [approved, errA] = await paged(token, 'admin.apps.approved.list', params);
// eslint-disable-next-line no-await-in-loop
const [restricted, errR] = await paged(token, 'admin.apps.restricted.list', params);
const name = String((team ?? {}).name ?? '').padEnd(16);
if (errA || errR) {
console.warn(`workspace ${String(teamId).padEnd(6)} ${name} unreadable: `
+ `${errA ?? errR}`);
continue;
}
const [state, why] = listingStatus(appId, appIds(approved), appIds(restricted));
const line = `workspace ${String(teamId).padEnd(6)} ${name} ${state}`;
if (state === 'approved') console.log(line);
else console.warn(line);
if (state === 'unlisted') console.log(` note ${why}`);
rows.push([teamId, state]);
}
const [shape, detail, named] = statusMap(rows);
const shapeLine = `shape ${shape.padEnd(16)} ${detail}`;
if (shape === 'uniform-approved') console.log(shapeLine);
else console.warn(shapeLine);
const [queue, errQ] = await paged(token, 'admin.apps.requests.list', { limit: 100 });
const staleDays = Number(arg(args, '--stale-days', '30'));
const stalled = errQ ? [] : stalledRequests(queue, null, staleDays);
if (errQ) console.warn(`request unreadable admin.apps.requests.list: ${errQ}`);
for (const [requestId, days, who] of stalled) {
console.warn(`request ${String(requestId).padEnd(16)} ${days} day(s) old, ${who}`);
}
const workspaces = Number(arg(args, '--workspaces', '0')) || teams.length;
const [cover, coverWhy] = coverage(rows.length, workspaces);
const coverLine = `coverage ${cover.padEnd(16)} ${coverWhy}`;
if (cover === 'full') console.log(coverLine);
else console.warn(coverLine);
const [final, summary] = verdict(shape, named, stalled);
if (final === 'clean') {
console.log(`verdict clean ${summary}`);
console.log(' note: nothing here was approved, restricted or requested by this script');
return;
}
console.warn(`verdict ${final.padEnd(16)} ${summary}`);
if (named.length) {
console.warn(` repair: ask the org admin to approve the app in ${named.slice(0, 6).join(', ')}`);
}
if (stalled.length) {
console.warn(' repair: ask them to action the pending requests above; nobody has '
+ 'refused you, the queue simply has not been read');
}
console.warn(' repair: an unlisted status is not a refusal and not a clearance; '
+ "confirm the org's app approval setting with the administrator");
console.warn(' note: nothing here was approved, restricted or requested by this script');
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 list pages and request rows, and the clock is passed in rather than read, so the aging tests are not a source of intermittent failures at midnight. Three assertions carry the note. An app on neither list must come back unlisted and the reason must say that this is not a clearance. A map that is approved in most workspaces and restricted in a couple must come back split with the restricted workspaces named, because those names are the whole message to the administrator. And scope_args must refuse to send team_id and enterprise_id together, which is the documented exclusivity that would otherwise be discovered against a live organization.
import pytest
from slack_app_approval_map import (
READS, app_ids, coverage, listing_status, request_age, scope_args,
stalled_requests, status_map, verdict,
)
APP = "A0123"
NOW = 1_700_000_000.0
DAY = 86400.0
def test_only_list_methods_are_ever_called():
assert all(name.endswith(".list") for name in READS)
def test_the_two_scopes_cannot_be_combined():
with pytest.raises(ValueError):
scope_args(team_id="T1", enterprise_id="E1")
def test_each_scope_produces_its_own_parameter():
assert scope_args(team_id="T1")[0]["team_id"] == "T1"
assert scope_args(enterprise_id="E1")[0]["enterprise_id"] == "E1"
assert "team_id" not in scope_args(enterprise_id="E1")[0]
def test_app_ids_are_read_from_the_nested_app_object():
assert app_ids([{"app": {"id": "A1"}}, {"app_id": "A2"}, {}]) == ["A1", "A2"]
def test_a_restricted_app_is_named_as_such():
state, why = listing_status(APP, [], [APP])
assert state == "restricted"
assert "restricted list" in why
def test_an_unlisted_app_is_not_reported_as_allowed():
state, why = listing_status(APP, ["A9"], ["A8"])
assert state == "unlisted"
assert "do not report" in why
assert "allowed" not in state
def test_being_on_both_lists_is_surfaced_rather_than_resolved():
assert listing_status(APP, [APP], [APP])[0] == "contradictory"
def test_the_split_is_the_finding_and_it_names_the_workspaces():
rows = [("T1", "approved"), ("T2", "approved"), ("T3", "restricted"),
("T4", "restricted"), ("T5", "unlisted")]
shape, detail, named = status_map(rows)
assert shape == "split"
assert named == ["T3", "T4"]
assert "restricted in 2" in detail
def test_a_uniform_approval_names_nobody():
shape, _detail, named = status_map([("T1", "approved"), ("T2", "approved")])
assert shape == "uniform-approved"
assert named == []
def test_unlisted_everywhere_has_its_own_shape():
assert status_map([("T1", "unlisted")])[0] == "unlisted-everywhere"
def test_a_request_is_aged_against_a_clock_you_pass_in():
days, detail = request_age({"date_created": NOW - 41 * DAY,
"user": {"id": "U7"}, "team": {"id": "T14"}}, NOW)
assert days == 41.0
assert "U7" in detail and "T14" in detail
def test_a_request_without_a_date_is_unknown_rather_than_zero():
assert request_age({"id": "R1"}, NOW)[0] is None
def test_only_the_old_requests_are_findings_and_the_oldest_leads():
queue = [{"id": "R1", "date_created": NOW - 41 * DAY},
{"id": "R2", "date_created": NOW - 2 * DAY},
{"id": "R3", "date_created": NOW - 60 * DAY}]
stalled = stalled_requests(queue, NOW, 30)
assert [row[0] for row in stalled] == ["R3", "R1"]
def test_a_partial_map_says_the_rest_are_unmeasured():
state, why = coverage(8, 40)
assert state == "partial"
assert "not approved" in why
def test_a_full_map_says_full():
assert coverage(39, 39)[0] == "full"
def test_the_verdict_counts_workspaces_and_requests_together():
state, summary = verdict("split", ["T3"], [("R1", 41.0, "")])
assert state == "2 finding(s)"
assert "1 restricted" in summary
def test_a_clean_org_says_so():
assert verdict("uniform-approved", [], [])[0] == "clean"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
READS, appIds, coverage, listingStatus, requestAge, scopeArgs, stalledRequests,
statusMap, verdict,
} from './slack-app-approval-map.mjs';
const APP = 'A0123';
const NOW = 1700000000;
const DAY = 86400;
test('only list methods are ever called', () => {
assert.equal(READS.every((name) => name.endsWith('.list')), true);
});
test('the two scopes cannot be combined', () => {
assert.throws(() => scopeArgs({ teamId: 'T1', enterpriseId: 'E1' }));
});
test('each scope produces its own parameter', () => {
assert.equal(scopeArgs({ teamId: 'T1' })[0].team_id, 'T1');
assert.equal(scopeArgs({ enterpriseId: 'E1' })[0].enterprise_id, 'E1');
assert.equal('team_id' in scopeArgs({ enterpriseId: 'E1' })[0], false);
});
test('app ids are read from the nested app object', () => {
assert.deepEqual(appIds([{ app: { id: 'A1' } }, { app_id: 'A2' }, {}]), ['A1', 'A2']);
});
test('a restricted app is named as such', () => {
const [state, why] = listingStatus(APP, [], [APP]);
assert.equal(state, 'restricted');
assert.match(why, /restricted list/);
});
test('an unlisted app is not reported as allowed', () => {
const [state, why] = listingStatus(APP, ['A9'], ['A8']);
assert.equal(state, 'unlisted');
assert.match(why, /do not report/);
});
test('being on both lists is surfaced rather than resolved', () => {
assert.equal(listingStatus(APP, [APP], [APP])[0], 'contradictory');
});
test('the split is the finding and it names the workspaces', () => {
const rows = [['T1', 'approved'], ['T2', 'approved'], ['T3', 'restricted'],
['T4', 'restricted'], ['T5', 'unlisted']];
const [shape, detail, named] = statusMap(rows);
assert.equal(shape, 'split');
assert.deepEqual(named, ['T3', 'T4']);
assert.match(detail, /restricted in 2/);
});
test('a uniform approval names nobody', () => {
const [shape, , named] = statusMap([['T1', 'approved'], ['T2', 'approved']]);
assert.equal(shape, 'uniform-approved');
assert.deepEqual(named, []);
});
test('unlisted everywhere has its own shape', () => {
assert.equal(statusMap([['T1', 'unlisted']])[0], 'unlisted-everywhere');
});
test('a request is aged against a clock you pass in', () => {
const [days, detail] = requestAge({ date_created: NOW - 41 * DAY,
user: { id: 'U7' }, team: { id: 'T14' } }, NOW);
assert.equal(days, 41);
assert.match(detail, /U7/);
assert.match(detail, /T14/);
});
test('a request without a date is unknown rather than zero', () => {
assert.equal(requestAge({ id: 'R1' }, NOW)[0], null);
});
test('only the old requests are findings and the oldest leads', () => {
const queue = [{ id: 'R1', date_created: NOW - 41 * DAY },
{ id: 'R2', date_created: NOW - 2 * DAY },
{ id: 'R3', date_created: NOW - 60 * DAY }];
assert.deepEqual(stalledRequests(queue, NOW, 30).map(([id]) => id), ['R3', 'R1']);
});
test('a partial map says the rest are unmeasured', () => {
const [state, why] = coverage(8, 40);
assert.equal(state, 'partial');
assert.match(why, /not approved/);
});
test('a full map says full', () => {
assert.equal(coverage(39, 39)[0], 'full');
});
test('the verdict counts workspaces and requests together', () => {
const [state, summary] = verdict('split', ['T3'], [['R1', 41, '']]);
assert.equal(state, '2 finding(s)');
assert.match(summary, /1 restricted/);
});
test('a clean org says so', () => {
assert.equal(verdict('uniform-approved', [], [])[0], 'clean');
});
FAQ
How is this different from the app_access_restricted note?
That note starts from a runtime error that some people get and some do not, and resolves it into a cohort: the four users who were refused are all multi-channel guests, or all in one workspace. This note starts from your app id and produces a map over the organization's workspaces, showing where your app is approved and where it is restricted. Its most common finding produces no runtime error for anybody, because it is an approval request nobody has actioned, and nothing has been refused yet.
I have no admin token. Can I still see this?
Not directly. An app's listing status is not exposed to the app itself; the three list methods all want a user token held by an org Admin or Owner. What is left is inference from the outside: the aggregate app_access_restricted rate for a given team id, and the fact that installs from one customer have stopped. If the customer is willing, the fastest path is asking their administrator to look at Organization settings rather than trying to deduce it.
The app is on neither list. Is that good news?
It is not news at all, which is why the script refuses to call it allowed. Whether an unlisted app can be installed depends on whether the organization requires app approval, and neither of these methods reports that setting. In an org that requires approval, unlisted means members must raise a request first, and that request is where installs go to wait. Ask the administrator which posture their org is in, once, and record it per customer.
Why check per workspace when there is an org-level list?
Because both exist and they can disagree. The methods accept either a team_id or an enterprise_id, and an app can be approved at the org level and restricted in an individual workspace, which is the exact configuration that produces the phone call about the app working everywhere except in Legal. Querying only the org level answers a narrower question than the one you have, and the per-workspace map is what names the workspaces to raise.
Can the script ask for approval, or approve the app itself?
No, on both counts, and deliberately. The approve and restrict methods change the customer's organization and no diagnostic in this section calls a method like that. Raising an approval request is also an action taken by a member of that organization, not by your app. What this produces is the sentence an administrator can act on: this app is restricted in these two workspaces, and these requests have been pending for forty-one days.
Related field notes
- the same policy met by one person, at runtime
- the token these three reads need
- the install that fails before any of this
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.
- admin.apps.restricted.list method reference - Slack Docs
- admin.apps.approved.list method reference - Slack Docs
- admin.apps.requests.list method reference - Slack Docs
- admin.teams.list method reference - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.