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
73 lines
2.5 KiB
Go
73 lines
2.5 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
)
|
|
|
|
const (
|
|
// operatorAccountName is the instance-scoped service account the operator
|
|
// key belongs to.
|
|
operatorAccountName = "terdut-operator"
|
|
|
|
// operatorKeyName names the one key SeedOperatorKey manages on it, so a
|
|
// rotation replaces that key and leaves any others alone.
|
|
operatorKeyName = "seed"
|
|
)
|
|
|
|
// SeedOperatorKey makes key the operator account's credential: it creates the
|
|
// instance-scoped service account if needed and replaces its "seed" key with
|
|
// this one. Idempotent, so every replica can run it at every start, and a
|
|
// rotated key simply wins on the next restart.
|
|
//
|
|
// The key is hashed like any other, so only the caller that generated it ever
|
|
// holds the raw value. An empty key does nothing.
|
|
func SeedOperatorKey(ctx context.Context, db *sql.DB, key string) error {
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("seed operator key: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
// Serialise replicas starting together; transaction-scoped, so it needs no
|
|
// explicit release.
|
|
if _, err := tx.ExecContext(ctx, "SELECT pg_advisory_xact_lock($1)", operatorKeyLockKey); err != nil {
|
|
return fmt.Errorf("seed operator key: lock: %w", err)
|
|
}
|
|
|
|
var accountID int64
|
|
err = tx.QueryRowContext(ctx,
|
|
"SELECT id FROM service_accounts WHERE name = $1 AND scope = $2",
|
|
operatorAccountName, models.ServiceAccountScopeInstance).Scan(&accountID)
|
|
if err == sql.ErrNoRows {
|
|
err = tx.QueryRowContext(ctx,
|
|
"INSERT INTO service_accounts (name, scope) VALUES ($1, $2) RETURNING id",
|
|
operatorAccountName, models.ServiceAccountScopeInstance).Scan(&accountID)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("seed operator key: account: %w", err)
|
|
}
|
|
|
|
if _, err := tx.ExecContext(ctx,
|
|
"DELETE FROM service_account_keys WHERE service_account_id = $1 AND name = $2",
|
|
accountID, operatorKeyName); err != nil {
|
|
return fmt.Errorf("seed operator key: drop old key: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx,
|
|
"INSERT INTO service_account_keys (service_account_id, key_hash, name) VALUES ($1, $2, $3)",
|
|
accountID, hashToken(key), operatorKeyName); err != nil {
|
|
return fmt.Errorf("seed operator key: store key: %w", err)
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// operatorKeyLockKey is the transaction-scoped advisory lock SeedOperatorKey
|
|
// holds; distinct from the other lock keys in this package.
|
|
const operatorKeyLockKey int64 = 7265_0010
|