9029d48584
- TERDUT_OPERATOR_KEY creates or re-keys the instance-scoped service account
"terdut-operator" at every start, so terdut-operator needs no bootstrap
handshake. An instance-scoped account now acts as owner of every team's
configuration, but is not a member of any team.
- POST /api/teams takes an external_id (instance service accounts only) and
is idempotent on it, so automation finds its own team again after a crash
instead of adopting by display name. GET /api/teams?name= is removed.
- Integration and dead man's switch names are unique per team (409). The
escalation PUT accepts usernames and resolves them itself.
- The 18 migrations are squashed into 001_schema.sql, with no Default team.
TERDUT_DEADMAN_* and the env seeding of switches are removed: teams carry
their own. Existing development databases must be recreated.
Security and robustness:
- GET /api/users no longer returns other people's email or ntfy topic to
non-admins.
- The access log records the route pattern, so integration keys and ack
tokens in the path are not written to the log. Server errors are logged.
- Rate limits take the client address TERDUT_TRUSTED_PROXIES hops from the
right of X-Forwarded-For instead of trusting the first, forgeable entry.
- /api/bootstrap runs in a transaction under an advisory lock, so two
concurrent calls cannot both create an administrator.
- API key last_used_at is written at most every five minutes.
Cleanup: remove GET /api/incidents/{id}/alerts, unused exports, SQLite
remnants in comments and config.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
75 lines
2.2 KiB
Go
75 lines
2.2 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
)
|
|
|
|
// teamOIDCGroups is one team's own OIDC binding: which group, if any, grants
|
|
// member access and which grants owner access. The same shape answers GET and
|
|
// is accepted by PUT. An empty string means no group grants that role here.
|
|
type teamOIDCGroups struct {
|
|
MemberGroup string `json:"member_group"`
|
|
OwnerGroup string `json:"owner_group"`
|
|
}
|
|
|
|
// handleGetTeamOIDCGroups answers which groups control a team's membership.
|
|
// Member-gated like the member list itself: this is part of "who is in the
|
|
// team and why", not a setting only an owner should be able to see.
|
|
func handleGetTeamOIDCGroups(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamMember(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var g teamOIDCGroups
|
|
err := db.QueryRowContext(r.Context(),
|
|
"SELECT COALESCE(oidc_member_group, ''), COALESCE(oidc_owner_group, '') FROM teams WHERE id = $1",
|
|
teamID).Scan(&g.MemberGroup, &g.OwnerGroup)
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, g)
|
|
}
|
|
}
|
|
|
|
// handleSetTeamOIDCGroups sets which groups control a team's membership.
|
|
//
|
|
// Owner-gated, the same as the schedule, the integrations and the escalation
|
|
// ladder: this decides who can end up in the team, which is exactly the kind
|
|
// of thing only the team's own owner (or an administrator repairing it) should
|
|
// be able to change. An empty string clears a binding.
|
|
func handleSetTeamOIDCGroups(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var req teamOIDCGroups
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
|
|
if _, err := db.ExecContext(r.Context(), `
|
|
UPDATE teams
|
|
SET oidc_member_group = NULLIF($1, ''),
|
|
oidc_owner_group = NULLIF($2, '')
|
|
WHERE id = $3`,
|
|
req.MemberGroup, req.OwnerGroup, teamID); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|