Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ LOG_MODE=full
# Slack user groups that declare roles (Business+ only; user groups do not
# exist on the free plan). Leave unset to maintain the roster purely by CSV.
# Restrict "Create and edit user groups" to Owners/Admins before using these.
# Seeds only. Once hawk-mod is running these four are set from Slack with
# `/hawkmod config`, which stores them in the database and shows you where each
# value came from. A value set there wins over anything here.
STUDENT_USERGROUP=students
ADULT_USERGROUP=adults
ADULT_USERGROUP=mentors

# Schedules (cron, in TZ). Defaults shown.
TZ=America/New_York
Expand Down
18 changes: 17 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ that has shipped. All SQL lives in `src/db/repo.ts` and nowhere else, and
better-sqlite3 is synchronous — repo functions are not `async`, so anything
`await`ed in this codebase is Slack, not the database.

**Settings a Slack admin owns live in the database, not the environment.**
`src/settings.ts` resolves each one **database → environment → unset**, so the
env var is a _seed_ rather than the source of truth and an existing host keeps
working with no flag day. `/hawkmod config` shows every value **and where it
came from**, which is the question actually asked when the roster looks wrong.
`SETTINGS` is an allowlist and must stay one: Slack credentials cannot be set
from Slack, and `TOKEN_ENCRYPTION_KEY` must never be reachable — changing it
makes every stored token undecryptable and every enrolled adult invisible while
coverage still reads 100%. A user group handle is validated against Slack before
it is stored, because a stored typo reads exactly like an empty group. Changing
a role group re-syncs immediately; leaving it until 3am would mean the setting
looked applied and was not. Every change lands in `setting_changes`.

Note `settings.ts` reads `process.env` directly rather than through `config()` —
it is reachable from the CLI, and `config()` there would throw at import time.

**`config()` is all or nothing.** One zod parse of the whole environment, on
first use; a missing `SLACK_*` var throws for every caller. That is why
`dataDir()` and `logMode()` exist as separate readers — the CLI runs
Expand All @@ -104,7 +120,7 @@ is idempotent, which is what makes the duplicate events harmless). The
reconciliation in `domain/rules/rosterSync.ts` is pure and **may only ever add
monitoring, never subtract it**: a person dropped from the students group stays
a student, since the alternative is silently ending someone's monitoring. Only
an explicit move into the adults group leaves `student`, and that raises
an explicit move into the mentors group leaves `student`, and that raises
`roster_drift`. Every change lands in `role_changes` — Slack's audit log API is
Grid-only, so that table is the only trail. Do not make this bidirectional.

Expand Down
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ _Avoid_: Lead coach, admin role, superuser
These two are the distinction most easily lost, and the one that matters most.

**Declared**:
Present in the Slack user group that names a role — `@students` or `@adults`.
Present in the Slack user group that names a role — `@students` or `@mentors`.
Cheap, reversible, and edited by hand in Slack or through hawk-mod. A
declaration is a statement of intent, not a fact about monitoring.
_Avoid_: Enrolled, rostered, assigned
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ npm run cli -- import-consents consents.csv

### Roles from Slack user groups

Set `STUDENT_USERGROUP` and `ADULT_USERGROUP` to user group handles (e.g.
`students`, `adults`) and each sweep reconciles roles from them, so membership
Set the student and mentor groups with `/hawkmod config` — this team uses
`students` and `mentors` — and each sweep reconciles roles from them, so
membership
is managed in Slack rather than by editing a CSV. Group membership is by Slack
user ID, which removes the email-matching failure below entirely: a group
member with no roster row gets one created from their Slack profile instead of
Expand All @@ -203,8 +204,11 @@ Two properties make this safe to rely on:

- **Membership is only ever added, never subtracted.** Dropping someone from
the students group does _not_ un-student them — that would silently end their
monitoring. The only way out of `student` is being put in the adults group,
which is deliberate and raises a `roster_drift` finding.
monitoring. The only way out of `student` is being put in the mentors
group, which is deliberate and raises a `roster_drift` finding.
- **The handle is checked before it is stored.** A user group that does not
resolve is refused outright, because a stored typo reads exactly like an
empty group: nobody rostered, nobody monitored, no complaint.
- **Every role change is recorded** in `role_changes` with who, when, and from
what. Slack's audit log API is Enterprise Grid only, so on Business+ this
table is the sole durable trail of who was monitored when.
Expand Down
35 changes: 35 additions & 0 deletions migrations/0007_settings.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- Settings a Slack admin can change without a shell on the host.
--
-- `authz.ts` already made this argument once, about who may administer
-- hawk-mod: "an app whose first-run instruction is 'SSH into the server' is
-- broken." The same was still true of which user group declares students — a
-- decision a Slack admin makes, that only a Linode login could change, and that
-- reads identically to "the group is empty" when it is wrong.
--
-- Credentials stay in the environment: you cannot configure from Slack the
-- things that let hawk-mod reach Slack, and TOKEN_ENCRYPTION_KEY changing would
-- make every stored token undecryptable. What moves here is policy, not
-- identity.
--
-- Lives in the same SQLite file as everything else, so it survives a redeploy
-- and is already covered by the backup step in docs/deploy.md.
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_by TEXT NOT NULL,
updated_at TEXT NOT NULL
);

-- Which user group declares who is monitored is a youth-protection fact, so
-- changing it leaves the same kind of trail as changing somebody's role does.
-- Slack's audit log API is Enterprise Grid only; this is the trail.
CREATE TABLE setting_changes (
id INTEGER PRIMARY KEY,
key TEXT NOT NULL,
from_value TEXT, -- NULL when the setting was previously unset
to_value TEXT NOT NULL,
actor TEXT NOT NULL,
actor_name TEXT NOT NULL,
changed_at TEXT NOT NULL
);
CREATE INDEX setting_changes_key_idx ON setting_changes (key, changed_at);
4 changes: 2 additions & 2 deletions scripts/setup-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -554,11 +554,11 @@ stage "Slack — user groups that declare roles"
say "hawk-mod reads two user groups to decide who is a student and who is an"
say "adult. Give it the TEST groups."
step "In Slack: create the groups if they don't exist, and put ONLY accounts"
note " you control in them — you in the adults one, your second account"
note " you control in them — you in the mentors one, your second account"
note " in the students one."
say ""
ask STUDENT_USERGROUP "Students group handle (without @):"
ask ADULT_USERGROUP "Adults group handle (without @):"
ask ADULT_USERGROUP "Mentors group handle (without @):"
write_env STUDENT_USERGROUP "$STUDENT_USERGROUP"
write_env ADULT_USERGROUP "$ADULT_USERGROUP"
note "Membership is only ever added to the roster, never subtracted — dropping"
Expand Down
35 changes: 9 additions & 26 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,20 @@ const schema = z.object({
SLACK_STATE_SECRET: z.string().min(16),
PUBLIC_URL: z.string().url(),
PORT: z.coerce.number().int().positive().default(3000),
ALERT_CHANNEL_ID: z.string().min(1),
// Seeds only, all four of them. A value set from Slack wins — see
// src/settings.ts. Kept optional so a fresh install can be configured
// entirely from Slack, and so an existing host keeps working unchanged.
ALERT_CHANNEL_ID: z.string().optional(),
DATA_DIR: z.string().default("./data"),
TOKEN_ENCRYPTION_KEY: z.string().min(1),
LOG_MODE: z.enum(["full", "metadata"]).default("full"),
// Slack user groups that declare roles. Both optional: leave them unset and
// the roster is maintained purely by CSV import.
// Slack user groups that declare roles. Seeds for the settings of the same
// name; leave both unset and unconfigured and the roster is maintained purely
// by CSV import.
STUDENT_USERGROUP: z.string().optional(),
ADULT_USERGROUP: z.string().optional(),
// Further user group handles hawk-mod may edit, comma separated. The two
// role groups above are always editable; this widens the allowlist to
// subteams (@programming, @drive-team) without widening it to everything.
//
// The allowlist is not authorization — every caller is already a Workspace
// Owner or Admin who could edit any group in Slack's own UI. It is blast
// radius. `usergroups.users.update` replaces a group's entire membership, so
// a bad plan does not corrupt a group, it empties one, and this bounds how
// many groups a single bug can reach.
// Seed for the `managed-groups` setting; see src/settings.ts for what the
// allowlist is actually for.
MANAGED_USERGROUPS: z.string().optional(),
TZ: z.string().default("America/New_York"),
SWEEP_CRON: z.string().default("0 3 * * *"),
Expand Down Expand Up @@ -62,17 +59,3 @@ export function dataDir(): string {
export function logMode(): "full" | "metadata" {
return process.env.LOG_MODE === "metadata" ? "metadata" : "full";
}

/** Handles hawk-mod is permitted to edit, lowercased and without the `@`. */
export function managedGroupHandles(): Set<string> {
const cfg = config();
const extra = (cfg.MANAGED_USERGROUPS ?? "")
.split(",")
.map((h) => h.trim())
.filter(Boolean);
return new Set(
[cfg.STUDENT_USERGROUP, cfg.ADULT_USERGROUP, ...extra]
.filter((h): h is string => Boolean(h))
.map((h) => h.replace(/^@/, "").toLowerCase())
);
}
76 changes: 76 additions & 0 deletions src/db/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,82 @@ export function revokeConsent(consentId: number, on: string): void {
.run(on, consentId);
}

/* -------------------------------------------------------------- settings */

/** A setting a Slack admin has changed, or undefined if none has. */
export function getSetting(key: string): string | undefined {
const row = db()
.prepare("SELECT value FROM settings WHERE key = ?")
.get(key) as { value: string } | undefined;
return row?.value;
}

export function listSettingRows(): Array<{
key: string;
value: string;
updated_by: string;
updated_at: string;
}> {
return db().prepare("SELECT * FROM settings ORDER BY key").all() as Array<{
key: string;
value: string;
updated_by: string;
updated_at: string;
}>;
}

/**
* Changes a setting and records who changed it, in one transaction.
*
* Which user group declares who is monitored is a youth-protection fact, so it
* leaves the same kind of trail as changing somebody's role does. Slack's audit
* log API is Enterprise Grid only; `setting_changes` is the trail.
*/
export function setSetting(args: {
key: string;
value: string;
actor: string;
actorName: string;
}): void {
const now = nowIso();
const previous = getSetting(args.key) ?? null;
db().transaction(() => {
db()
.prepare(
`INSERT INTO settings (key, value, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (key) DO UPDATE SET
value = excluded.value,
updated_by = excluded.updated_by,
updated_at = excluded.updated_at`
)
.run(args.key, args.value, args.actorName, now);
db()
.prepare(
`INSERT INTO setting_changes (key, from_value, to_value, actor,
actor_name, changed_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(args.key, previous, args.value, args.actor, args.actorName, now);
})();
}

export function listSettingChanges(limit = 100) {
return db()
.prepare(
`SELECT * FROM setting_changes ORDER BY changed_at DESC, id DESC LIMIT ?`
)
.all(limit) as Array<{
id: number;
key: string;
from_value: string | null;
to_value: string;
actor: string;
actor_name: string;
changed_at: string;
}>;
}

/* --------------------------------------------------------- group changes */

export type GroupChangeInput = {
Expand Down
2 changes: 1 addition & 1 deletion src/domain/rules/rosterSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type Person, type Role } from "../people.js";
export type GroupMembership = {
/** Slack ids in the group designating students. */
students: ReadonlySet<string>;
/** Slack ids in the group designating adults/adults. */
/** Slack ids in the group designating adults — @mentors, in this team. */
adults: ReadonlySet<string>;
};

Expand Down
24 changes: 21 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { config } from "./config.js";
import { db } from "./db/client.js";
import { startSchedules } from "./jobs/schedule.js";
import { log } from "./logger.js";
import { setting } from "./settings.js";
import { createApp } from "./slack/app.js";

async function main() {
Expand All @@ -10,16 +11,33 @@ async function main() {
const app = createApp();
await app.start(cfg.PORT);
startSchedules();
const students = setting("student-group");
const mentors = setting("mentor-group");
const alerts = setting("alert-channel");

log.info("hawk-mod started", {
port: cfg.PORT,
logMode: cfg.LOG_MODE,
installUrl: `${cfg.PUBLIC_URL}/slack/install`,
// Silence here used to look identical to "the groups are empty".
// Silence here used to look identical to "the groups are empty". The
// source matters as much as the value: "which group?" and "who set it?"
// are the two questions asked when the roster looks wrong.
roleSource:
cfg.STUDENT_USERGROUP || cfg.ADULT_USERGROUP
? `user groups (@${cfg.STUDENT_USERGROUP ?? "-"} / @${cfg.ADULT_USERGROUP ?? "-"})`
students.value || mentors.value
? `user groups (@${students.value ?? "-"} [${students.source}] / ` +
`@${mentors.value ?? "-"} [${mentors.source}])`
: "CSV import only — no user groups configured",
alertChannel: alerts.value ? `${alerts.value} [${alerts.source}]` : "unset",
});

// A finding nobody is told about is the failure this project is built to
// avoid, so an unset alert channel is an error at boot rather than a surprise
// the first time something goes wrong.
if (!alerts.value) {
log.error("no alert channel configured; findings will not be announced", {
fix: "/hawkmod config set alert-channel #channel",
});
}
}

main().catch((err) => {
Expand Down
22 changes: 12 additions & 10 deletions src/jobs/syncRoles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { WebClient } from "@slack/web-api";
import { config } from "../config.js";
import {
createPersonFromSlack,
peopleBySlackId,
Expand All @@ -10,6 +9,7 @@ import {
import { dedupeKey } from "../domain/findings.js";
import { reconcileRoles } from "../domain/rules/rosterSync.js";
import { log } from "../logger.js";
import { settingValue } from "../settings.js";
import { raise } from "../raise.js";
import { fetchProfiles, resolveGroup } from "../slack/userGroups.js";

Expand Down Expand Up @@ -49,7 +49,9 @@ const SOURCE = "usergroup_sync";
export async function syncRolesFromUserGroups(
client: WebClient
): Promise<RoleSyncStats> {
const cfg = config();
// From Slack if an admin has set it, from the environment otherwise.
const studentHandle = settingValue("student-group");
const adultHandle = settingValue("mentor-group");
const stats: RoleSyncStats = {
enabled: false,
studentsInGroup: 0,
Expand All @@ -62,22 +64,22 @@ export async function syncRolesFromUserGroups(
missingGroups: 0,
};

if (!cfg.STUDENT_USERGROUP && !cfg.ADULT_USERGROUP) return stats;
if (!studentHandle && !adultHandle) return stats;
stats.enabled = true;

const studentGroup = cfg.STUDENT_USERGROUP
? await resolveGroup(client, cfg.STUDENT_USERGROUP)
const studentGroup = studentHandle
? await resolveGroup(client, studentHandle)
: null;
const adultGroup = cfg.ADULT_USERGROUP
? await resolveGroup(client, cfg.ADULT_USERGROUP)
const adultGroup = adultHandle
? await resolveGroup(client, adultHandle)
: null;

// A configured group that does not exist is a typo, and it reads exactly
// like an empty group: nobody rostered, nothing monitored, no complaint.
// Being quieter by being blinder is the failure this project must not have.
for (const [handle, group] of [
[cfg.STUDENT_USERGROUP, studentGroup],
[cfg.ADULT_USERGROUP, adultGroup],
[studentHandle, studentGroup],
[adultHandle, adultGroup],
] as const) {
if (!handle || group) continue;
stats.missingGroups += 1;
Expand All @@ -88,7 +90,7 @@ export async function syncRolesFromUserGroups(
summary:
`Configured user group @${handle} does not exist in this workspace. ` +
`Nobody is being rostered from it, so nobody is being monitored ` +
`through it either.`,
`through it either. Fix it with \`/hawkmod config\`.`,
subjectRef: handle,
});
}
Expand Down
Loading
Loading