From 511bbb51130c6ecb9f067e90d7a32e32998735a0 Mon Sep 17 00:00:00 2001 From: Ty Tremblay Date: Thu, 13 Aug 2026 16:12:09 -0400 Subject: [PATCH] Answer to Slack's admins, not to a roster role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- CLAUDE.md | 10 +- README.md | 25 +++-- docs/deploy.md | 2 +- docs/examples/roster.example.csv | 6 +- docs/policy-mapping.md | 13 ++- migrations/0005_authority_from_slack.sql | 30 ++++++ scripts/setup-local.sh | 10 +- src/cli/index.ts | 12 +-- src/domain/guidance.ts | 8 +- src/domain/people.ts | 36 +++---- src/domain/rules/rosterSync.ts | 11 +-- src/health.ts | 2 +- src/jobs/sweep.ts | 31 +++++- src/monitor/conversations.ts | 2 +- src/slack/actions.ts | 23 ++--- src/slack/app.ts | 4 +- src/slack/authz.ts | 120 +++++++++++++++++++++++ src/slack/commands.ts | 22 ++--- src/slack/installStore.ts | 2 +- src/slack/modals.ts | 31 +++--- test/authz.test.ts | 117 ++++++++++++++++++++++ test/guidance.test.ts | 6 +- test/rosterSync.test.ts | 10 +- test/rules.test.ts | 8 +- 24 files changed, 414 insertions(+), 127 deletions(-) create mode 100644 migrations/0005_authority_from_slack.sql create mode 100644 src/slack/authz.ts create mode 100644 test/authz.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ba12e2d..2300ac2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,10 +28,12 @@ npx tsx --test --test-name-pattern "two adults" test/rules.test.ts ``` CLI subcommands: `import-roster`, `import-consents`, `set-role`, `sweep`, -`backfill`, `findings [status]`, `export-conversation [out.json]`. -`set-role` is the bootstrap that matters — the user-group sync only ever -assigns `student` or `adult`, so the first `lead_coach` must be set from the -CLI or nobody can run `/hawkmod` at all. +`backfill`, `findings [status]`, `export-conversation [out.json]`. None of +them is a bootstrap step: administrative access is Slack's Workspace +Owner/Admin flags, read live in `src/slack/authz.ts`, so a fresh install is +usable by whoever installed it without anyone touching the host. Don't +reintroduce a roster role that grants access — see the "Lead Coach" note in +`docs/policy-mapping.md`. ## What this is diff --git a/README.md b/README.md index bc05e8f..5c5be5e 100644 --- a/README.md +++ b/README.md @@ -49,18 +49,19 @@ appear: - **adults who have not authorized hawk-mod, or who revoked it** — see below - channels containing students with fewer than two screened adults (§4.2) - fewer than two workspace owners, or a student holding Owner/Admin (§6) +- fewer than two of those Owners/Admins being screened adults on the roster (§3) Findings are posted once to a private channel, deduplicated, auto-closed when -the underlying problem goes away, and closable by a Lead Coach from **Resolve** -and **Acknowledge** buttons on the alert itself. +the underlying problem goes away, and closable by a workspace admin from +**Resolve** and **Acknowledge** buttons on the alert itself. -**The adults involved get a private nudge, not just the Lead Coaches.** When a +**The adults involved get a private nudge, not just the coaches.** When a DM raises a finding, hawk-mod sends each adult in it a direct message naming the rule and the concrete fix — add a second screened adult, or move it to a channel. It is deliberately a colleague's heads-up rather than a warning: most violations are people who did not know the rule, and an adult who feels accused moves the conversation somewhere nobody can see it. The nudge goes once per -occurrence, it says plainly that a Lead Coach has been notified, and it never +occurrence, it says plainly that a coach has been notified, and it never replaces the finding. **Students never receive one.** Youth protection governs adult conduct toward @@ -169,7 +170,9 @@ and gates on it. 4. `docker compose up -d` (or `npm install && npm run dev`). For the server — DNS, TLS, backups, and the public-reachability requirement — see [docs/deploy.md](docs/deploy.md). -5. A Lead Coach installs the app: visit `$PUBLIC_URL/slack/install`. +5. A Slack workspace Owner or Admin installs the app: visit + `$PUBLIC_URL/slack/install`. Whoever can install it can administer it — + there is nothing to grant afterwards. 6. Import the roster and the consents you have already collected: ```bash @@ -235,8 +238,9 @@ projection of Slack — not a second roster to keep in sync. email,full_name,role,ypp_completed_on,mentor_ready_on,cori_completed_on,active,notes ``` -`role` is one of `student`, `adult`, `lead_coach`, `admin`, -`district_observer` — the last being the MPS administrator seat from §8. Dates +`role` is one of `student`, `adult`, `district_observer` — the last being the +MPS administrator seat from §8. Nothing in this file grants access to +`/hawkmod`; that is Slack's Owner/Admin, and only Slack's. Dates are `YYYY-MM-DD`. Email is the join key; Slack IDs are matched automatically once people sign up. **If a Slack account's email doesn't match a roster row it resolves to an unknown account, not a student** — which produces silence rather @@ -258,7 +262,9 @@ the signed copy is actually filed. ## Commands -In Slack, restricted to Lead Coaches and admins: +In Slack, restricted to the workspace's Owners and Admins — read live from +Slack on every command, so granting or revoking access is something you do in +Slack's own admin settings and nowhere else: ``` /hawkmod status | enroll | findings [kind] | whois @user @@ -266,6 +272,9 @@ In Slack, restricted to Lead Coaches and admins: /hawkmod ack | resolve | sweep | backfill ``` +There is deliberately no roster role that confers this. A student who somehow +holds Owner or Admin is refused anyway, and reported as a §6 violation. + `screening` and `consent` open a form in Slack. They are how screening dates and consent records get in day to day — the CSV importers below are a season-start bulk load, not a workflow. Both record who entered what and when, diff --git a/docs/deploy.md b/docs/deploy.md index aaedeff..8e8f53a 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -90,7 +90,7 @@ before setup — running and waiting. ## 4. Set up the workspace -1. A Lead Coach visits `https://hawk-mod.example.org/slack/install`. +1. A workspace Owner or Admin visits `https://hawk-mod.example.org/slack/install`. 2. Invite the bot to the private findings channel. 3. Import the roster and consents (see the README). 4. Send every adult the same install URL, and watch `/hawkmod status` until diff --git a/docs/examples/roster.example.csv b/docs/examples/roster.example.csv index a662292..42fd3ed 100644 --- a/docs/examples/roster.example.csv +++ b/docs/examples/roster.example.csv @@ -1,9 +1,9 @@ email,full_name,role,ypp_completed_on,ypt_completed_on,mentor_ready_on,cori_completed_on,active,notes -lead1@example.org,Alex Lead,lead_coach,2026-09-01,2026-09-01,2026-09-01,2024-08-15,1, -lead2@example.org,Sam Lead,lead_coach,2026-09-01,2026-09-01,2026-09-01,2025-01-20,1, +lead1@example.org,Alex Lead,adult,2026-09-01,2026-09-01,2026-09-01,2024-08-15,1,Workspace Owner — §3 +lead2@example.org,Sam Lead,adult,2026-09-01,2026-09-01,2026-09-01,2025-01-20,1,Workspace Owner — §3 adult1@example.org,Jordan Adult,adult,2026-09-03,2026-09-03,2026-09-03,2024-09-10,1, adult2@example.org,Casey Adult,adult,2026-09-05,2026-09-05,2026-09-05,2026-01-05,1,MPS employee — see policy IJNDD observer@melroseschools.com,MPS Administrator,district_observer,2026-08-20,2026-08-20,2026-08-20,2025-06-01,1,District visibility seat (§8) -treasurer@example.org,Pat Treasurer,admin,2026-09-01,2026-09-01,2026-09-01,2024-08-15,1,Workspace owner #2 +treasurer@example.org,Pat Treasurer,adult,2026-09-01,2026-09-01,2026-09-01,2024-08-15,1,Workspace Owner #3 student1@example.org,Robin Student,student,,,,,1, student2@example.org,Avery Student,student,,,,,1, diff --git a/docs/policy-mapping.md b/docs/policy-mapping.md index b40bfd8..23cbd88 100644 --- a/docs/policy-mapping.md +++ b/docs/policy-mapping.md @@ -8,7 +8,7 @@ Each control from _Moving Team Communication to Slack_, and what carries it. | 2 | Parental consent on file before a student account exists | `consent.ts`; `team_join` event raises `unconsented_account` the moment an account appears; nightly sweep re-checks | | 2 | Consent re-collected annually | `CONSENT_VALID_YEARS = 1`; consents expire rather than linger | | 2 | Consents kept on file and producible | `consents` table records `document_ref`; the signed copies themselves live wherever the team files them — **manual** | -| 3 | Two YPP-screened Lead Coaches | roster `role = lead_coach` + `screening_lapsed` findings | +| 3 | Two YPP-screened Lead Coaches | `workspace_config` `screened_admins`: two of the workspace's Owners/Admins must be screened adults on the roster, plus `screening_lapsed` findings | | 3 | Written communications copied to a second adult | `dmPolicy` — two screened adults required in any student conversation | | 4.1 | No 1:1 adult–student DMs, ever | `dmPolicy` `one_to_one_adult_student`, raised on each new message and on backfill; a message after a finding is closed raises it again (`recurrence.ts`) | | 4.2 | Two screened adults in every channel students are in | `twoAdults.ts`; re-evaluated on every join/leave, plus nightly | @@ -36,6 +36,17 @@ training inside it is required. hawk-mod tracks it and reports it as outstanding, but it never blocks screened-adult status. Requiring it would have excluded adults who had done everything actually asked of them. +**"Lead Coach" is not a role hawk-mod stores.** It used to be: a roster role +that granted every administrative action in the app. That put a +youth-protection permission behind a label anyone with CLI access could type, +checked against nothing, and it meant a freshly installed app had no +administrator at all until someone opened a shell on the host. Authority is now +read live from Slack's Workspace Owner/Admin flags, which the workspace already +manages and audits. §3 is still checked — see the `screened_admins` row — but +it asks the question of the people who demonstrably hold the authority rather +than of a self-assigned label. A student holding Owner or Admin is refused +regardless (§6), and reported. + **The screening and the training run on different clocks.** The background screening is valid for longer than a year; the training is annual. Treating both as annual flags people who are current, and an alert channel that cries diff --git a/migrations/0005_authority_from_slack.sql b/migrations/0005_authority_from_slack.sql new file mode 100644 index 0000000..4a8f7fb --- /dev/null +++ b/migrations/0005_authority_from_slack.sql @@ -0,0 +1,30 @@ +-- Administrative authority moved out of the roster and onto Slack's own +-- Workspace Owner/Admin flags (see src/slack/authz.ts). The `lead_coach` and +-- `admin` roles existed only to answer "may this person run /hawkmod", which +-- Slack already answers, and answering it here meant a fresh install had no +-- administrator at all until someone ran the CLI on the host. +-- +-- Both collapse to `adult`. No rule loses anything: enrollment, screening, and +-- the two-adult rule already treated all three identically. + +INSERT INTO role_changes + (person_id, slack_user_id, from_role, to_role, source, detail, changed_at) +SELECT id, slack_user_id, role, 'adult', 'migration', + json_object('migration', '0005_authority_from_slack', + 'reason', 'authority now read from Slack admin/owner'), + strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +FROM people +WHERE role IN ('lead_coach', 'admin'); + +UPDATE people +SET role = 'adult', + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') +WHERE role IN ('lead_coach', 'admin'); + +-- The CHECK constraint on people.role still lists the two retired names, and +-- stays that way on purpose. Dropping a CHECK in SQLite means rebuilding the +-- table, and `people` is the parent of four ON DELETE CASCADE foreign keys — +-- consents among them. 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'. diff --git a/scripts/setup-local.sh b/scripts/setup-local.sh index 880f423..ea2e3a7 100755 --- a/scripts/setup-local.sh +++ b/scripts/setup-local.sh @@ -630,14 +630,8 @@ const {listPeople}=require("./dist/src/db/repo.js"); for (const p of listPeople()) console.log(` ${p.role.padEnd(18)} ${p.full_name}`); ' 2>/dev/null || note " (could not list — check the logs)" say "" -say "The group sync only ever assigns student or adult, so make yourself a" -say "Lead Coach — /hawkmod commands need one to exist." -ask LEAD_COACH_EMAIL "Your Slack email:" -"${COMPOSE[@]}" exec -T hawk-mod node dist/src/cli/index.js \ - set-role "$LEAD_COACH_EMAIL" lead_coach | sed 's/^/ /' \ - || warn "couldn't set the role — is that the email on your Slack account?" -note "Being a lead_coach also survives the next sync: someone already recorded" -note "as a lead coach is not demoted for being in the adults group." +note "Nothing to grant yourself here: /hawkmod answers to Slack's Workspace" +note "Owners and Admins, read live. You installed the app, so you can run it." say "" step "Now record consent for the test student, from Slack:" note " /hawkmod consent @their-account" diff --git a/src/cli/index.ts b/src/cli/index.ts index 71f3cfd..1638581 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -22,9 +22,9 @@ const USAGE = `hawk-mod cli cori_completed_on,active,notes import-consents email,signed_on,form_version,guardian_name, guardian_email,document_ref,recorded_by[,expires_on] - set-role role: student|adult|lead_coach|admin| - district_observer. Bootstraps the first Lead - Coach, since /hawkmod needs one to exist. + set-role role: student|adult|district_observer. + Nothing here grants access to /hawkmod — that + is Slack's Owner/Admin, read live. sweep run the compliance sweep backfill walk enrolled adults' DM history findings [status] list findings (default: open) @@ -124,9 +124,9 @@ function exportConversation(id: string, out?: string) { } /** - * The user group sync only ever assigns `student` or `adult`, so the first - * Lead Coach has to be set from outside Slack — otherwise nobody can run - * /hawkmod at all. Recorded in role_changes like any other role change. + * The user group sync only ever assigns `student` or `adult`, so + * `district_observer` (§8) has to be set from outside Slack. Recorded in + * role_changes like any other role change. */ function setRole(who: string, role: string) { if (!ROLES.includes(role as Role)) { diff --git a/src/domain/guidance.ts b/src/domain/guidance.ts index c89b720..a54fa53 100644 --- a/src/domain/guidance.ts +++ b/src/domain/guidance.ts @@ -6,7 +6,7 @@ import type { DmVerdict } from "./rules/dmPolicy.js"; * * The audience is someone who almost certainly did not know the rule, so the * tone is a colleague's, not a compliance system's: name the rule, give the - * concrete fix, say plainly that a Lead Coach can see it, and do not imply + * concrete fix, say plainly that a coach can see it, and do not imply * anyone is in trouble. An adult who feels accused moves the conversation * somewhere nobody can see it, which is the opposite of what this is for. * @@ -23,8 +23,8 @@ const OPENING = ":wave: Hi — a quick heads-up, not a telling-off."; const CLOSING = "Conversations that include a student are recorded for youth-protection " + - "audit, and a Lead Coach has been notified as usual. Nothing here is an " + - "accusation — if you are not sure what to do, just ask a Lead Coach."; + "audit, and a coach has been notified as usual. Nothing here is an " + + "accusation — if you are not sure what to do, just ask a coach."; function mentions(ids: string[]): string { return ids.map((id) => `<@${id}>`).join(", "); @@ -67,7 +67,7 @@ export function guidanceFor(verdict: DmVerdict): string | null { `This conversation includes a student and ` + `${verdict.unknownIds.length} account(s) that are not on the team ` + `roster, so ${APP_NAME} cannot tell whether they are a screened adult.`, - "*To put it right:* ask a Lead Coach to add them to the roster, or " + + "*To put it right:* ask a coach to add them to the roster, or " + "to the students or adults user group. Until then the " + "conversation counts as an exception.", ]; diff --git a/src/domain/people.ts b/src/domain/people.ts index bb6b576..66b37ad 100644 --- a/src/domain/people.ts +++ b/src/domain/people.ts @@ -1,12 +1,12 @@ import type { IsoDate } from "./dates.js"; -export const ROLES = [ - "student", - "adult", - "lead_coach", - "admin", - "district_observer", -] as const; +/** + * What the roster says about a person, and nothing more. Deliberately no role + * for "runs hawk-mod": administrative authority is Slack's Owner/Admin flags, + * read live in `slack/authz.ts`, so there is nothing here to keep in sync with + * Slack and nothing to bootstrap by hand on a fresh install. + */ +export const ROLES = ["student", "adult", "district_observer"] as const; export type Role = (typeof ROLES)[number]; @@ -43,29 +43,19 @@ export function isStudent(m: Member): boolean { } /** - * Anyone on the roster who is not a student — `lead_coach`, `admin`, and - * `district_observer` included, not just the `adult` role. Do not rewrite this - * as `role === "adult"`: a Lead Coach alone with a student is exactly the - * situation the rules exist for. Unknown members are not adults either, so an - * unidentified account can never satisfy the two-adult rule. + * Anyone on the roster who is not a student — `district_observer` included, + * not just the `adult` role, and a workspace Owner is no exception. Seniority + * has never been an argument for being alone with a student; that is exactly + * the situation the rules exist for. Unknown members are not adults either, so + * an unidentified account can never satisfy the two-adult rule. */ export function isAdult(m: Member): boolean { return isKnown(m) && m.role !== "student"; } -/** Roles that may hold Workspace Owner/Admin. Never a student (§6). */ -export function mayAdministerWorkspace(p: Person): boolean { - return p.role === "lead_coach" || p.role === "admin"; -} - /** Roles whose DMs hawk-mod expects to monitor via an enrolled user token. */ export function requiresEnrollment(p: Person): boolean { - return ( - p.active === 1 && - (p.role === "adult" || - p.role === "lead_coach" || - p.role === "district_observer") - ); + return p.active === 1 && p.role !== "student"; } export function label(m: Member): string { diff --git a/src/domain/rules/rosterSync.ts b/src/domain/rules/rosterSync.ts index d0ad50a..05644b8 100644 --- a/src/domain/rules/rosterSync.ts +++ b/src/domain/rules/rosterSync.ts @@ -23,12 +23,7 @@ export type RoleDecision = /** In both groups at once; too ambiguous to act on. */ | { kind: "conflict"; slackId: string; personId: number | null }; -const ADULT_ROLES: Role[] = [ - "adult", - "lead_coach", - "admin", - "district_observer", -]; +const ADULT_ROLES: Role[] = ["adult", "district_observer"]; function isAdultRole(role: Role): boolean { return ADULT_ROLES.includes(role); @@ -74,8 +69,8 @@ export function reconcileRoles( continue; } - // Someone already recorded as a lead coach, admin, or district observer is - // in the adults group too; that is agreement, not a demotion to `adult`. + // A district observer in the adults group is agreement, not a demotion to + // plain `adult`. if (target === "adult" && isAdultRole(person.role)) { decisions.push({ kind: "unchanged", slackId }); continue; diff --git a/src/health.ts b/src/health.ts index dc63d72..6165895 100644 --- a/src/health.ts +++ b/src/health.ts @@ -15,7 +15,7 @@ type Health = { * later, when the failing thing would be a DM nobody recorded. * * `installed: false` is not unhealthy — a freshly deployed container is - * expected to be running and waiting for a Lead Coach to install the app. + * expected to be running and waiting for an admin to install the app. */ export function healthHandler(_req: unknown, res: ServerResponse): void { let body: Health; diff --git a/src/jobs/sweep.ts b/src/jobs/sweep.ts index 0991098..dfbafe2 100644 --- a/src/jobs/sweep.ts +++ b/src/jobs/sweep.ts @@ -150,7 +150,9 @@ export async function runSweep(): Promise { } } - if (p.role === "adult" || p.role === "lead_coach") { + // Every adult on the roster, `district_observer` included (§8: they count + // as an adult only once screening dates are recorded). + if (p.role !== "student") { const screening = screeningStatus(p, asOf); if (!screening.current) { stats.screeningLapsed += 1; @@ -286,6 +288,33 @@ export async function runSweep(): Promise { } } + /* ---- responsible adults (§3) ------------------------------------------ */ + + // FIRST requires two screened Lead Coaches. hawk-mod used to answer this + // from a `lead_coach` roster role — a label anyone could type, that granted + // administrative access, and that nothing checked against reality. The + // people who actually hold the authority are the workspace's Owners and + // Admins, which Slack knows and nobody can quietly self-assign here, so the + // question becomes: are at least two of them screened adults on the roster? + const responsible = users.filter((u) => { + if (u.isDeleted || u.isBot || !(u.isOwner || u.isAdmin)) return false; + const person = rosterBySlackId.get(u.id); + if (!person || person.role === "student" || person.active !== 1) + return false; + return screeningStatus(person, asOf).current; + }); + if (responsible.length < 2) { + await emit({ + kind: "workspace_config", + dedupeKey: dedupeKey("workspace_config", "screened_admins"), + severity: "violation", + summary: + `${responsible.length} of the workspace's Owners/Admins are screened ` + + `adults on the roster; FIRST requires two (§3).`, + detail: { screened: responsible.map((u) => u.id) }, + }); + } + const closed = autoResolveMissing(SWEEP_OWNED, seen); stats.findingsClosed = closed.length; // Redraw each one: a finding closed in the database but still showing diff --git a/src/monitor/conversations.ts b/src/monitor/conversations.ts index 0e8f663..fdc54af 100644 --- a/src/monitor/conversations.ts +++ b/src/monitor/conversations.ts @@ -176,7 +176,7 @@ export async function raiseDmViolation( { at: tsToIso(ts) } ); - // Only when the Lead Coaches were told. Guidance rides on the alert rather + // Only when the alert channel was told. Guidance rides on the alert rather // than on the message, so an adult gets one nudge per occurrence instead of // one per line they type — and it never quietly replaces the finding. if (alerted) await nudgeAdults(verdict); diff --git a/src/slack/actions.ts b/src/slack/actions.ts index 8a1b6bc..f5cb353 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -1,9 +1,9 @@ import type { App } from "@slack/bolt"; import { closeFinding } from "../close.js"; -import { getFinding, personBySlackId } from "../db/repo.js"; -import { mayAdministerWorkspace } from "../domain/people.js"; +import { getFinding } from "../db/repo.js"; import { log } from "../logger.js"; import { ACK_ACTION, RESOLVE_ACTION, refreshFinding } from "./alerts.js"; +import { administrator } from "./authz.js"; const NOTE_MODAL = "hawkmod_finding_note"; const NOTE_BLOCK = "note"; @@ -79,13 +79,13 @@ export function registerActions(app: App): void { const findingId = Number(payload.actions?.[0]?.value); if (!Number.isInteger(findingId) || !payload.trigger_id) return; - const caller = personBySlackId(payload.user.id); + const caller = await administrator(client, payload.user.id); const finding = getFinding(findingId); if (!finding) return; // Anyone who can see the channel can click; only the people responsible // for youth protection may close. - if (!caller || !mayAdministerWorkspace(caller)) { + if (!caller) { await client.views.open({ trigger_id: payload.trigger_id, view: { @@ -97,7 +97,7 @@ export function registerActions(app: App): void { type: "section", text: { type: "mrkdwn", - text: "Only Lead Coaches and workspace admins can close findings.", + text: "Only Slack workspace Owners and Admins can close findings.", }, }, ], @@ -118,13 +118,14 @@ export function registerActions(app: App): void { }); } - app.view(NOTE_MODAL, async ({ ack, body, view }) => { - const caller = personBySlackId(body.user.id); - if (!caller || !mayAdministerWorkspace(caller)) { + app.view(NOTE_MODAL, async ({ ack, body, view, client }) => { + const caller = await administrator(client, body.user.id); + if (!caller) { await ack({ response_action: "errors", errors: { - [NOTE_BLOCK]: "Only Lead Coaches and admins can close findings.", + [NOTE_BLOCK]: + "Only Slack workspace Owners and Admins can close findings.", }, }); return; @@ -145,11 +146,11 @@ export function registerActions(app: App): void { try { await ack(); - await closeFinding(findingId, caller.full_name, note, status); + await closeFinding(findingId, caller.name, note, status); log.info("finding closed from Slack", { findingId, status, - by: caller.full_name, + by: caller.name, }); } catch (err) { log.error("could not close finding", { findingId, error: String(err) }); diff --git a/src/slack/app.ts b/src/slack/app.ts index f82769d..c92369f 100644 --- a/src/slack/app.ts +++ b/src/slack/app.ts @@ -75,7 +75,7 @@ export function createApp(): App { include a student, and records them for youth-protection audit. Conversations with no student in them are not recorded.

You can revoke this at any time from Slack → Settings - → Manage apps. Revoking is reported to the Lead Coaches, + → Manage apps. Revoking is reported to the coaches, because unmonitored is not the same as compliant.

` ); @@ -83,7 +83,7 @@ export function createApp(): App { failure: (error, _options, _req, res) => { log.error("oauth failure", { error: String(error) }); res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); - res.end("Authorization failed. Tell a Lead Coach."); + res.end("Authorization failed. Tell a coach."); }, }, }, diff --git a/src/slack/authz.ts b/src/slack/authz.ts new file mode 100644 index 0000000..391a0c2 --- /dev/null +++ b/src/slack/authz.ts @@ -0,0 +1,120 @@ +import type { WebClient } from "@slack/web-api"; +import { APP_NAME } from "../brand.js"; +import { personBySlackId } from "../db/repo.js"; +import type { Person } from "../domain/people.js"; +import { log } from "../logger.js"; + +/** + * Who may administer hawk-mod: Slack's own Workspace Owners and Admins, read + * live from `users.info`. + * + * This used to be a roster role. That was a second, hand-maintained copy of a + * fact Slack already knows, and it had a worse failure mode than being merely + * redundant: a fresh install had an empty roster, so *nobody* could run + * `/hawkmod`, and the only way out was a shell on the host running the CLI. + * An app whose first-run instruction is "SSH into the server" is broken. + * Whoever can install the app can now use it. + * + * The roster still says who is a student, who is an adult, and who has been + * screened. It no longer says who is in charge. + */ +export const NOT_PERMITTED = `${APP_NAME} is limited to Slack workspace Owners and Admins.`; + +export type Actor = { + slackUserId: string; + /** Roster name where there is one, Slack's profile name otherwise. */ + name: string; + /** The roster row, when this person has one. Admins need not be on it. */ + person: Person | undefined; +}; + +/** + * Long enough that a burst of button clicks is one API call, short enough that + * revoking someone's Slack admin revokes their access to findings within a + * minute. Promotions are picked up just as fast, which matters on first run. + */ +const TTL_MS = 60_000; + +const cache = new Map(); + +/** Only for tests, and for the rare caller that must not see a stale verdict. */ +export function forgetAuthorization(slackUserId?: string): void { + if (slackUserId) cache.delete(slackUserId); + else cache.clear(); +} + +type Deps = { + now?: number; + /** Injected by the tests so this module can be exercised without a database. */ + lookup?: (slackUserId: string) => Person | undefined; +}; + +/** + * Resolves the caller if they may administer hawk-mod, `null` if they may not. + * + * Fails closed: a Slack API error denies the action rather than allowing it. + * Findings name students and describe conduct concerns, so the cost of a + * wrongly-allowed read is much higher than the cost of a retry. + */ +export async function administrator( + client: WebClient, + slackUserId: string, + deps: Deps = {} +): Promise { + const now = deps.now ?? Date.now(); + const hit = cache.get(slackUserId); + if (hit && now - hit.at < TTL_MS) return hit.actor; + + const actor = await resolve( + client, + slackUserId, + deps.lookup ?? personBySlackId + ); + cache.set(slackUserId, { at: now, actor }); + return actor; +} + +async function resolve( + client: WebClient, + slackUserId: string, + lookup: (slackUserId: string) => Person | undefined +): Promise { + let user; + try { + user = (await client.users.info({ user: slackUserId })).user; + } catch (err) { + log.error("could not read caller from Slack; denying", { + slackUserId, + error: String(err), + }); + return null; + } + if (!user || user.deleted || user.is_bot) return null; + + const person = lookup(slackUserId); + + // §6: a student must never hold Owner or Admin. The sweep raises that as a + // violation, but the finding is written to a channel this student could then + // read. Refusing here means the workspace being misconfigured for an hour + // does not also hand a minor the youth-protection case file. + if (person?.role === "student") { + log.warn("student holds workspace admin; refused", { + slackUserId, + person: person.email, + }); + return null; + } + + if (!(user.is_admin || user.is_owner || user.is_primary_owner)) return null; + + return { + slackUserId, + name: + person?.full_name ?? + user.profile?.real_name ?? + user.real_name ?? + user.name ?? + slackUserId, + person, + }; +} diff --git a/src/slack/commands.ts b/src/slack/commands.ts index 071503a..d29c4eb 100644 --- a/src/slack/commands.ts +++ b/src/slack/commands.ts @@ -15,15 +15,12 @@ import { } from "../db/repo.js"; import { today } from "../domain/dates.js"; import { severityEmoji } from "../domain/findings.js"; -import { - mayAdministerWorkspace, - requiresEnrollment, - type Person, -} from "../domain/people.js"; +import { requiresEnrollment, type Person } from "../domain/people.js"; import { consentStatus } from "../domain/rules/consent.js"; import { screeningStatus } from "../domain/rules/screening.js"; import { log } from "../logger.js"; import { backfillAll } from "../monitor/backfill.js"; +import { administrator, NOT_PERMITTED } from "./authz.js"; import { openConsent, openScreening } from "./modals.js"; import { runSweep } from "../jobs/sweep.js"; import { syncRolesFromUserGroups } from "../jobs/syncRoles.js"; @@ -50,14 +47,11 @@ export function registerCommands(app: App): void { app.command("/hawkmod", async ({ command, ack, respond, client }) => { await ack(); - // Findings name students and describe conduct concerns. Only the adults - // responsible for youth protection get to read them. - const caller = personBySlackId(command.user_id); - if (!caller || !mayAdministerWorkspace(caller)) { - await respond({ - response_type: "ephemeral", - text: `${APP_NAME} is limited to Lead Coaches and workspace admins.`, - }); + // Findings name students and describe conduct concerns. Only the people + // Slack already trusts to run the workspace get to read them. + const caller = await administrator(client, command.user_id); + if (!caller) { + await respond({ response_type: "ephemeral", text: NOT_PERMITTED }); return; } @@ -140,7 +134,7 @@ export function registerCommands(app: App): void { } await closeFinding( id, - caller.full_name, + caller.name, note, sub === "ack" ? "acknowledged" : "resolved" ); diff --git a/src/slack/installStore.ts b/src/slack/installStore.ts index 5a141ed..786fe1c 100644 --- a/src/slack/installStore.ts +++ b/src/slack/installStore.ts @@ -31,7 +31,7 @@ function teamKey( /** * Two kinds of row live here: * - * 'bot' — one per workspace, installed once by a Lead Coach. Posts alerts. + * 'bot' — one per workspace, installed once by a workspace admin. Posts alerts. * 'user' — one per enrolled adult, holding that adult's user token. This is * what lets hawk-mod see DMs at all; Slack exposes no other way to * read them below Enterprise Grid. diff --git a/src/slack/modals.ts b/src/slack/modals.ts index f42e404..e489f58 100644 --- a/src/slack/modals.ts +++ b/src/slack/modals.ts @@ -6,14 +6,14 @@ import { insertConsent, listConsents, personById, - personBySlackId, setScreeningDates, type ScreeningField, } from "../db/repo.js"; import { closeFinding } from "../close.js"; import { today } from "../domain/dates.js"; import { dedupeKey } from "../domain/findings.js"; -import { mayAdministerWorkspace, type Person } from "../domain/people.js"; +import { type Person } from "../domain/people.js"; +import { administrator } from "./authz.js"; import { consentStatus, defaultExpiry, @@ -234,12 +234,14 @@ async function settleConsent(personId: number): Promise { } export function registerViews(app: App): void { - app.view(SCREENING_MODAL, async ({ ack, body, view }) => { - const caller = personBySlackId(body.user.id); - if (!caller || !mayAdministerWorkspace(caller)) { + app.view(SCREENING_MODAL, async ({ ack, body, view, client }) => { + const caller = await administrator(client, body.user.id); + if (!caller) { await ack({ response_action: "errors", - errors: { ypp: "Only Lead Coaches and admins can record screening." }, + errors: { + ypp: "Only Slack workspace Owners and Admins can record screening.", + }, }); return; } @@ -273,14 +275,14 @@ export function registerViews(app: App): void { const changed = setScreeningDates({ personId, values, - recordedBy: caller.full_name, + recordedBy: caller.name, source: "slack_modal", }); await ack(); await settleScreening(personId); log.info("screening recorded", { personId, - by: caller.full_name, + by: caller.name, changed, }); } catch (err) { @@ -297,13 +299,14 @@ export function registerViews(app: App): void { } }); - app.view(CONSENT_MODAL, async ({ ack, body, view }) => { - const caller = personBySlackId(body.user.id); - if (!caller || !mayAdministerWorkspace(caller)) { + app.view(CONSENT_MODAL, async ({ ack, body, view, client }) => { + const caller = await administrator(client, body.user.id); + if (!caller) { await ack({ response_action: "errors", errors: { - signed_on: "Only Lead Coaches and admins can record consent.", + signed_on: + "Only Slack workspace Owners and Admins can record consent.", }, }); return; @@ -339,11 +342,11 @@ export function registerViews(app: App): void { guardianName: textOf(state, "guardian_name"), guardianEmail: textOf(state, "guardian_email") || null, documentRef: textOf(state, "document_ref") || null, - recordedBy: caller.full_name, + recordedBy: caller.name, }); await ack(); await settleConsent(personId); - log.info("consent recorded", { personId, by: caller.full_name }); + log.info("consent recorded", { personId, by: caller.name }); } catch (err) { log.error("consent submission failed", { personId, error: String(err) }); await ack({ diff --git a/test/authz.test.ts b/test/authz.test.ts new file mode 100644 index 0000000..0b30473 --- /dev/null +++ b/test/authz.test.ts @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; +import type { WebClient } from "@slack/web-api"; +import type { Person, Role } from "../src/domain/people.js"; +import { administrator, forgetAuthorization } from "../src/slack/authz.js"; + +type SlackFlags = { + is_admin?: boolean; + is_owner?: boolean; + is_primary_owner?: boolean; + is_bot?: boolean; + deleted?: boolean; + real_name?: string; +}; + +/** A WebClient with just enough of `users.info` to exercise the gate. */ +function slack(flags: SlackFlags | Error): { + client: WebClient; + calls: () => number; +} { + let calls = 0; + const client = { + users: { + info: async () => { + calls += 1; + if (flags instanceof Error) throw flags; + return { user: { id: "U1", name: "someone", ...flags } }; + }, + }, + } as unknown as WebClient; + return { client, calls: () => calls }; +} + +function person(role: Role): Person { + return { + id: 1, + slack_user_id: "U1", + email: "p1@example.org", + full_name: "Roster Name", + role, + active: 1, + ypp_completed_on: null, + ypt_completed_on: null, + mentor_ready_on: null, + cori_completed_on: null, + notes: null, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + }; +} + +const nobody = () => undefined; + +describe("who may administer hawk-mod", () => { + beforeEach(() => forgetAuthorization()); + + it("admits a workspace admin who is not on the roster at all", async () => { + // The whole point: a fresh install has an empty roster, and whoever + // installed the app must still be able to use it. + const { client } = slack({ is_admin: true, real_name: "Ty" }); + const actor = await administrator(client, "U1", { lookup: nobody }); + assert.equal(actor?.name, "Ty"); + assert.equal(actor?.person, undefined); + }); + + it("admits owners and primary owners", async () => { + for (const flags of [{ is_owner: true }, { is_primary_owner: true }]) { + forgetAuthorization(); + const { client } = slack(flags); + assert.ok(await administrator(client, "U1", { lookup: nobody })); + } + }); + + it("refuses an ordinary member, roster row or not", async () => { + const { client } = slack({ real_name: "Someone" }); + assert.equal( + await administrator(client, "U1", { lookup: () => person("adult") }), + null + ); + }); + + it("refuses a student who holds workspace admin (§6)", async () => { + // The sweep raises this as a violation; the finding names other students, + // so this one must not be readable by the student it is about. + const { client } = slack({ is_owner: true }); + assert.equal( + await administrator(client, "U1", { lookup: () => person("student") }), + null + ); + }); + + it("fails closed when Slack cannot be reached", async () => { + const { client } = slack(new Error("ratelimited")); + assert.equal(await administrator(client, "U1", { lookup: nobody }), null); + }); + + it("prefers the roster name, for a stable audit trail", async () => { + const { client } = slack({ is_admin: true, real_name: "tyt" }); + const actor = await administrator(client, "U1", { + lookup: () => person("adult"), + }); + assert.equal(actor?.name, "Roster Name"); + }); + + it("caches a verdict briefly, then re-reads it", async () => { + const { client, calls } = slack({ is_admin: true }); + await administrator(client, "U1", { lookup: nobody, now: 0 }); + await administrator(client, "U1", { lookup: nobody, now: 59_000 }); + assert.equal(calls(), 1); + await administrator(client, "U1", { lookup: nobody, now: 61_000 }); + assert.equal( + calls(), + 2, + "a revoked admin must lose access within a minute" + ); + }); +}); diff --git a/test/guidance.test.ts b/test/guidance.test.ts index f5020d3..42c9e02 100644 --- a/test/guidance.test.ts +++ b/test/guidance.test.ts @@ -71,7 +71,7 @@ describe("guidance to an adult", () => { assert.match(text, /add another screened adult/); }); - it("points an unknown participant at a Lead Coach, not at the adult", () => { + it("points an unknown participant at a coach, not at the adult", () => { const verdict = classifyConversation( "mpim", [ @@ -85,7 +85,7 @@ describe("guidance to an adult", () => { assert.equal(verdict.violation, "unknown_participant_with_student"); const text = guidanceFor(verdict); assert.ok(text); - assert.match(text, /ask a Lead Coach/); + assert.match(text, /ask a coach/); }); it("never accuses, and always says the finding still stands", () => { @@ -101,6 +101,6 @@ describe("guidance to an adult", () => { assert.match(text, /not a telling-off/); assert.match(text, /Nothing here is an accusation/); // Guidance must never read as a substitute for the alert. - assert.match(text, /Lead Coach has been notified/); + assert.match(text, /coach has been notified/); }); }); diff --git a/test/rosterSync.test.ts b/test/rosterSync.test.ts index 0ebcbfb..7e6ec5f 100644 --- a/test/rosterSync.test.ts +++ b/test/rosterSync.test.ts @@ -52,15 +52,7 @@ describe("user group reconciliation", () => { ); }); - it("does not demote a lead coach who is also in the adults group", () => { - const decisions = reconcileRoles( - roster(person("U1", "lead_coach")), - groups([], ["U1"]) - ); - assert.equal(decisions[0]!.kind, "unchanged"); - }); - - it("does not demote the district observer either", () => { + it("does not demote the district observer", () => { const decisions = reconcileRoles( roster(person("U1", "district_observer")), groups([], ["U1"]) diff --git a/test/rules.test.ts b/test/rules.test.ts index 61aa5bc..bccdf1d 100644 --- a/test/rules.test.ts +++ b/test/rules.test.ts @@ -209,10 +209,10 @@ describe("DM policy", () => { assert.equal(verdict.severity, "violation"); }); - it("flags a 1:1 even when the adult is fully screened and a Lead Coach", () => { + it("flags a 1:1 even when the adult is fully screened and senior", () => { const verdict = classifyConversation( "im", - [screened("lead_coach"), person("student")], + [screened("district_observer"), person("student")], asOf ); assert.equal(verdict.violation, "one_to_one_adult_student"); @@ -272,7 +272,7 @@ describe("DM policy", () => { [ screened(), screened(), - screened("lead_coach"), + screened("district_observer"), person("student"), person("student"), person("student"), @@ -323,7 +323,7 @@ describe("two screened adults", () => { channelId: "C3", channelName: "build", isPrivate: false, - members: [screened(), screened("lead_coach"), person("student")], + members: [screened(), screened("district_observer"), person("student")], }, asOf );