a4fbd60441
The core of #4, and what #1 is for: terdut stops being one shared space. A team owns its incidents, alerts, schedule and integrations; a user sees exactly the teams they are in. Everything that existed moves into one Default team and every existing user becomes an owner of it, so the upgrade is a no-op for the people using it. Ingestion is the load-bearing half. An alert arrives on a team's integration key, and the key is both the credential and the routing: it says that the sender may post, and which team the alerts belong to. That also closes the unauthenticated webhook -- the old path stays for one release, deprecated and routed to the oldest team, so an upgrade does not stop delivering while somebody edits the Alertmanager config. Scoping is enforced in as few places as possible, because the failure mode is silent. serveAs loads the caller's memberships once; list queries carry `team_id = ANY(...)`; and every incident route goes through incidentIDParam, which now parses the id AND checks the team in the same call, so a new handler cannot remember the first half and forget the second. Anything in another team is 404, never 403: whether an incident exists is that team's business. Two bugs this found, both of which would have been silent: * upsertAlerts decided "is this a new occurrence" by looking up the fingerprint alone. Across teams that made team B's first alert look like a re-send of team A's, so it opened no incident at all. The lookups are keyed on (team_id, fingerprint) now, as the index is. * Every uniqueness rule was written for one tenant. Two teams watching two clusters legitimately see the same fingerprint, the same groupKey, and want somebody on call on the same day; all three constraints move to include team_id. Roles inside a team are separate from the system administrator flag: an owner configures the team, a member works its incidents, and an admin is NOT implicitly in every team -- administration is about accounts, not about reading other people's incidents. An admin can still repair a team whose owner has left, which is why requireTeamOwner lets them through. A shift can only be given to somebody in the team. Paging a person who cannot open the incident is worse than paging nobody. The UI is updated only as far as keeping it working: it loads the viewer's teams with the session and uses the first one, since nobody has a second yet. "On call now" shows every team the viewer is in, named only when there is more than one, so the common case reads exactly as before. The team switcher, badges and per-team settings pages are the next step. Breaking for API clients: the schedule endpoints moved under the team, and /api/schedule/current returns an array rather than an object or a 404. terdut-tui will need a version for that. Per-team dead-man configuration is deliberately not here. A heartbeat's incident already opens in the team whose key received it, which is the part that matters for isolation; moving the matchers out of env into per-team rows is a change to how deadman.go is configured rather than to who sees what. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
104 lines
5.3 KiB
SQL
104 lines
5.3 KiB
SQL
-- Teams: the unit of tenancy. Everything a person works on now belongs to one.
|
|
--
|
|
-- Until this migration the install was one shared space — every user saw every
|
|
-- alert and every incident, and the Alertmanager webhook was unauthenticated, so
|
|
-- anything that could reach the port could open an incident for everybody.
|
|
--
|
|
-- The shape, in one paragraph: a team owns its incidents, alerts, schedule and
|
|
-- integrations. A user belongs to as many teams as they like, with a role in
|
|
-- each: an `owner` configures the team, a `member` works its incidents. An
|
|
-- integration key is what an alert arrives on, and the key is what says which
|
|
-- team the alert belongs to.
|
|
--
|
|
-- EVERYTHING EXISTING MOVES INTO ONE DEFAULT TEAM, and every existing user
|
|
-- becomes an owner of it. That keeps an upgrade a no-op for the people using it:
|
|
-- the same queue, the same schedule, the same incidents, with a name on them.
|
|
|
|
CREATE TABLE teams (
|
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE,
|
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
|
);
|
|
|
|
-- role is free text with a CHECK rather than an enum, so adding a third role
|
|
-- later is a migration and not a type rewrite.
|
|
CREATE TABLE team_members (
|
|
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
|
|
joined_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
|
PRIMARY KEY (team_id, user_id)
|
|
);
|
|
|
|
CREATE INDEX team_members_user_idx ON team_members(user_id);
|
|
|
|
-- How alerts get in, and the only thing that says which team they belong to.
|
|
-- The key is stored as a SHA-256 hash, like api_keys and the ack tokens: a
|
|
-- leaked database gives nobody the ability to post alerts.
|
|
CREATE TABLE integrations (
|
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
team_id BIGINT NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK (kind IN ('alertmanager')),
|
|
name TEXT NOT NULL,
|
|
key_hash TEXT NOT NULL UNIQUE,
|
|
created_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint,
|
|
last_used_at BIGINT
|
|
);
|
|
|
|
CREATE INDEX integrations_team_idx ON integrations(team_id);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- The default team, and everything that already exists moving into it.
|
|
--
|
|
-- Created unconditionally, even on an empty install, so there is always a team
|
|
-- for the bootstrap user to land in and for the first integration to hang off.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
INSERT INTO teams (name) VALUES ('Default');
|
|
|
|
INSERT INTO team_members (team_id, user_id, role)
|
|
SELECT (SELECT id FROM teams WHERE name = 'Default'), id, 'owner' FROM users;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- team_id on everything a team owns.
|
|
--
|
|
-- Added nullable, backfilled, then made NOT NULL: adding a NOT NULL column with
|
|
-- no default to a table with rows is rejected, and a DEFAULT pointing at the
|
|
-- default team would quietly keep working after the default team is gone.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
ALTER TABLE alerts ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
|
|
ALTER TABLE incidents ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
|
|
ALTER TABLE schedule_entries ADD COLUMN team_id BIGINT REFERENCES teams(id) ON DELETE CASCADE;
|
|
|
|
UPDATE alerts SET team_id = (SELECT id FROM teams WHERE name = 'Default');
|
|
UPDATE incidents SET team_id = (SELECT id FROM teams WHERE name = 'Default');
|
|
UPDATE schedule_entries SET team_id = (SELECT id FROM teams WHERE name = 'Default');
|
|
|
|
ALTER TABLE alerts ALTER COLUMN team_id SET NOT NULL;
|
|
ALTER TABLE incidents ALTER COLUMN team_id SET NOT NULL;
|
|
ALTER TABLE schedule_entries ALTER COLUMN team_id SET NOT NULL;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- The uniqueness rules were all written for one tenant, and every one of them
|
|
-- is wrong now: two teams monitoring two clusters legitimately see the same
|
|
-- fingerprint, the same groupKey, and want somebody on call on the same day.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
ALTER TABLE alerts DROP CONSTRAINT alerts_fingerprint_key;
|
|
CREATE UNIQUE INDEX alerts_team_fingerprint_idx ON alerts(team_id, fingerprint);
|
|
|
|
DROP INDEX incidents_open_group_key_idx;
|
|
-- Still load-bearing, now per team: at most one OPEN incident per group_key
|
|
-- within a team. This is what makes "resolved incident + a new alert occurrence
|
|
-- = a new incident" work, and what the webhook's find-or-open lookup relies on.
|
|
CREATE UNIQUE INDEX incidents_open_group_key_idx
|
|
ON incidents(team_id, group_key) WHERE resolved_at IS NULL;
|
|
|
|
ALTER TABLE schedule_entries DROP CONSTRAINT schedule_entries_date_key;
|
|
CREATE UNIQUE INDEX schedule_entries_team_date_idx ON schedule_entries(team_id, date);
|
|
|
|
-- The list views all filter by team first.
|
|
CREATE INDEX alerts_team_received_idx ON alerts(team_id, received_at DESC);
|
|
CREATE INDEX incidents_team_triggered_idx ON incidents(team_id, triggered_at DESC);
|