Skip to content

Answer to Slack's admins, not to a roster role - #1

Merged
tytremblay merged 1 commit into
mainfrom
slack-admin-authority
Aug 13, 2026
Merged

Answer to Slack's admins, not to a roster role#1
tytremblay merged 1 commit into
mainfrom
slack-admin-authority

Conversation

@tytremblay

Copy link
Copy Markdown
Contributor

The problem

/hawkmod was gated on a lead_coach roster role. A fresh install has an empty roster, so a newly installed app had no administrator at all — the only way to get one was a shell on the host running set-role. That is what this fixes: an app whose first-run instruction is "SSH into the server" is broken, and the role was a hand-maintained second copy of a fact Slack already knows.

What changed

New src/slack/authz.ts resolves the caller via users.info and admits on is_admin || is_owner || is_primary_owner. Every gate uses it: /hawkmod, the screening and consent modals, and the Resolve/Acknowledge buttons with their note modal. mayAdministerWorkspace is deleted.

  • Fails closed. A Slack API error denies rather than allows — findings name students, so a retry costs less than a wrong read.
  • A student holding Owner/Admin is refused anyway. The sweep raises that misconfiguration as a §6 violation, and that finding names other students; handing it to the student it is about would be the worst outcome of "trust Slack".
  • 60-second cache. A burst of button clicks is one API call, and revoking someone's Slack admin revokes their hawk-mod access within the minute. Promotions land just as fast, which is what makes first run work.
  • users:read was already a bot scope, so no manifest change and no reinstall.

Roles

ROLES becomes student | adult | district_observer. admin goes along with lead_coach — it existed for the same reason and would otherwise be a vestigial label granting nothing. district_observer stays; §8 gives it a distinct meaning.

migrations/0005_authority_from_slack.sql collapses both retired roles into adult and records each one in role_changes. It deliberately leaves the old names in the CHECK constraint: dropping a CHECK in SQLite means rebuilding people, which is the parent of four ON DELETE CASCADE keys including consents, and a migration that can destroy consent records to tidy a constraint is a bad trade. Nothing can write the old values now — ROLES no longer contains them, the CLI validates against ROLES, and the user-group sync only ever writes student or adult.

§3 keeps its check

FIRST requires two YPP-screened Lead Coaches, and that mapped onto the removed role. Rather than drop the control, the sweep now asks whether at least two of the workspace's Owners/Admins are screened adults on the roster, emitting a workspace_config / screened_admins finding otherwise. Same question, put to the people who demonstrably hold the authority instead of to a label anyone could type. docs/policy-mapping.md records the reasoning.

Verification

npm run typecheck, npm test (61 pass; 7 new in test/authz.test.ts covering the non-rostered admin, owners and primary owners, ordinary members, the student-admin refusal, failing closed, audit naming, and cache expiry), and prettier --check all clean.

🤖 Generated with Claude Code

/hawkmod was gated on a `lead_coach` roster role. A fresh install has an
empty roster, so a newly installed app had no administrator at all: the
only way to get one was a shell on the host running `set-role`. An app
whose first-run instruction is "SSH into the server" is broken, and the
role was a hand-maintained second copy of something Slack already knows.

Authority now comes from Slack's Workspace Owner/Admin flags, read live
per command in `slack/authz.ts`. Whoever can install the app can run it.

- Fails closed: a Slack API error denies rather than allows. Findings
  name students, so a retry costs less than a wrong read.
- A student holding Owner/Admin is refused anyway. The sweep raises that
  as a §6 violation, and that finding names other students.
- 60s cache: a burst of button clicks is one API call, and revoking
  someone's Slack admin revokes their access within the minute.
- `users:read` was already a bot scope, so no manifest change.

`lead_coach` and `admin` are removed from ROLES — both existed only to
answer "may this person administer hawk-mod". Migration 0005 collapses
them into `adult` and records each in role_changes. It leaves the retired
names in the CHECK constraint on purpose: dropping a CHECK in SQLite
means rebuilding `people`, parent of four ON DELETE CASCADE keys
including `consents`, and destroying consent records to tidy a constraint
is a bad trade.

§3 keeps its check rather than losing it with the role. The sweep now
asks whether two of the workspace's Owners/Admins are screened adults on
the roster — the same question, put to the people who demonstrably hold
the authority instead of to a self-assigned label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tytremblay
tytremblay merged commit 905866f into main Aug 13, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant