-- 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);