server admin panel is now supports multiple operators profiles
This commit is contained in:
parent
e48160ac3a
commit
280321b902
25 changed files with 2197 additions and 128 deletions
400
cmd/telesrv-admin/adminusers_api.go
Normal file
400
cmd/telesrv-admin/adminusers_api.go
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
)
|
||||
|
||||
// Operator accounts are written here rather than through callAdminAPI like the
|
||||
// domain mutations are, on purpose. They are not a Telegram entity: they are
|
||||
// the console's own authentication, and routing them through the domain API
|
||||
// would mean the console cannot fix its own locked-out operators whenever that
|
||||
// service is unreachable -- exactly when you need to. Reads already go straight
|
||||
// to Postgres for the same reason, so this keeps one owner for one table.
|
||||
//
|
||||
// They do follow the panel's command convention: every mutation is a
|
||||
// /api/actions/* route that takes a reason, runs as a dry run first and returns
|
||||
// an admin.CommandResult. Granting somebody the run of the console deserves the
|
||||
// same "here is what this will do, confirm it" step as freezing an account.
|
||||
|
||||
// requireAdminsManage is the single gate for every operator-account route, so
|
||||
// none of them can be registered without it by accident.
|
||||
func (s *server) requireAdminsManage(next http.Handler) http.Handler {
|
||||
return s.scopedRoute(permissionAdminsManage, next)
|
||||
}
|
||||
|
||||
// errAdminUsernameTaken maps the unique-index violation to something the panel
|
||||
// can show, without leaking the constraint name.
|
||||
var errAdminUsernameTaken = errors.New("username is already taken")
|
||||
|
||||
// errLastManagerStanding guards against an edit that would leave nobody able to
|
||||
// administer operators. The break-glass credential could still recover it, but
|
||||
// that is a recovery path, not a thing to walk into by accident.
|
||||
var errLastManagerStanding = errors.New("this would leave no enabled account able to manage operators")
|
||||
|
||||
// errUsernameReserved guards the break-glass name, which authentication
|
||||
// resolves before the table is consulted.
|
||||
var errUsernameReserved = errors.New("this username is reserved for the built-in operator")
|
||||
|
||||
// createAdminConsoleUser inserts a new operator. token_epoch starts at 1; there
|
||||
// are no sessions to invalidate yet.
|
||||
func (s *server) createAdminConsoleUser(ctx context.Context, username, password string, permissions []string, enabled bool) (AdminConsoleUser, error) {
|
||||
if err := validateAdminUsername(username); err != nil {
|
||||
return AdminConsoleUser{}, err
|
||||
}
|
||||
// authenticateLogin resolves this name to the environment credential before
|
||||
// it ever reaches the table, so a row by this name could never be logged
|
||||
// into. Refuse it rather than storing an account that silently does nothing.
|
||||
if strings.EqualFold(strings.TrimSpace(username), breakGlassUsername) {
|
||||
return AdminConsoleUser{}, errUsernameReserved
|
||||
}
|
||||
hash, err := hashAdminPassword(password)
|
||||
if err != nil {
|
||||
return AdminConsoleUser{}, err
|
||||
}
|
||||
permissions = normalisePermissions(permissions)
|
||||
|
||||
var u AdminConsoleUser
|
||||
err = s.read.pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_console_users (username, password_hash, permissions, enabled)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+adminConsoleUserColumns,
|
||||
strings.TrimSpace(username), hash, permissions, enabled).
|
||||
Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled, &u.TokenEpoch,
|
||||
&u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return AdminConsoleUser{}, errAdminUsernameTaken
|
||||
}
|
||||
if err != nil {
|
||||
return AdminConsoleUser{}, fmt.Errorf("create admin console user: %w", err)
|
||||
}
|
||||
if u.Permissions == nil {
|
||||
u.Permissions = []string{}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// updateAdminConsoleUser changes permissions and/or enabled state.
|
||||
//
|
||||
// It deliberately does NOT move token_epoch. currentSessionPermissions re-reads
|
||||
// this row on every request, so a narrowed permission set applies from the
|
||||
// operator's next request and a disabled account is refused outright -- both
|
||||
// without ending a session. Bumping the epoch here would only sign someone out
|
||||
// mid-task to achieve what the re-read already achieves.
|
||||
//
|
||||
// A password change is different and does bump it: the password is not
|
||||
// re-checked per request, so nothing else would retire the old sessions.
|
||||
func (s *server) updateAdminConsoleUser(ctx context.Context, id int64, permissions []string, enabled bool) (AdminConsoleUser, error) {
|
||||
permissions = normalisePermissions(permissions)
|
||||
|
||||
var u AdminConsoleUser
|
||||
err := s.read.pool.QueryRow(ctx, `
|
||||
UPDATE admin_console_users
|
||||
SET permissions = $2,
|
||||
enabled = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING `+adminConsoleUserColumns,
|
||||
id, permissions, enabled).
|
||||
Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled, &u.TokenEpoch,
|
||||
&u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return AdminConsoleUser{}, errAdminUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return AdminConsoleUser{}, fmt.Errorf("update admin console user: %w", err)
|
||||
}
|
||||
if u.Permissions == nil {
|
||||
u.Permissions = []string{}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// setAdminConsoleUserPassword replaces the hash and bumps the epoch, so a
|
||||
// password change signs out whoever was using the old one -- which is the
|
||||
// point of changing it after a suspected compromise.
|
||||
func (s *server) setAdminConsoleUserPassword(ctx context.Context, id int64, password string) error {
|
||||
hash, err := hashAdminPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := s.read.pool.Exec(ctx, `
|
||||
UPDATE admin_console_users
|
||||
SET password_hash = $2, token_epoch = token_epoch + 1, updated_at = now()
|
||||
WHERE id = $1`, id, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set admin console user password: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errAdminUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalisePermissions trims, de-duplicates and collapses to the wildcard when
|
||||
// it is present, so "*" plus a list cannot be stored as something that reads
|
||||
// narrower than it is.
|
||||
func normalisePermissions(in []string) []string {
|
||||
seen := make(map[string]struct{}, len(in))
|
||||
out := make([]string, 0, len(in))
|
||||
for _, p := range in {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if p == permissionAll {
|
||||
return []string{permissionAll}
|
||||
}
|
||||
if _, dup := seen[p]; dup {
|
||||
continue
|
||||
}
|
||||
seen[p] = struct{}{}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- HTTP surface -----------------------------------------------------------
|
||||
|
||||
// adminUserActionRequest carries the panel's usual command envelope alongside
|
||||
// the operator fields. ID is absent when creating.
|
||||
type adminUserActionRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleListAdminUsersAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
users, err := s.read.ListAdminConsoleUsers(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
// The built-in operator has no database row, so it would otherwise be
|
||||
// invisible here -- a list of who can sign in that omits the account
|
||||
// with the most rights is worse than no list. It is reported first and
|
||||
// flagged as system; the panel renders it read-only, and every mutation
|
||||
// below refuses it anyway.
|
||||
"system": map[string]any{
|
||||
"username": breakGlassUsername,
|
||||
"permissions": newPanelPermissions(s.cfg.Permissions).List(),
|
||||
"enabled": true,
|
||||
"system": true,
|
||||
},
|
||||
"rows": users,
|
||||
// The vocabulary the panel offers when editing an account, so the list
|
||||
// of assignable rights lives in one place instead of being duplicated
|
||||
// in the frontend and drifting from what the routes actually check.
|
||||
"available_permissions": assignablePermissions(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleCreateAdminUserAPI runs as a dry run unless confirmed.
|
||||
func (s *server) handleCreateAdminUserAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body adminUserActionRequest
|
||||
if !s.decodeAdminUserAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "admin-operator-create")
|
||||
enabled := body.Enabled == nil || *body.Enabled
|
||||
permissions := normalisePermissions(body.Permissions)
|
||||
|
||||
// Validate on the dry run too, so "this will fail" is discovered before the
|
||||
// operator is asked to confirm rather than after.
|
||||
if err := validateAdminUsername(strings.TrimSpace(body.Username)); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, err)
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(body.Username), breakGlassUsername) {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, errUsernameReserved)
|
||||
return
|
||||
}
|
||||
if err := validateAdminPassword(body.Password); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, err)
|
||||
return
|
||||
}
|
||||
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: "create-admin-operator",
|
||||
Status: "ok",
|
||||
DryRun: true,
|
||||
Message: fmt.Sprintf("Would create operator %q with %d permission(s), %s.",
|
||||
strings.TrimSpace(body.Username), len(permissions), enabledWord(enabled)),
|
||||
Details: map[string]any{
|
||||
"username": strings.TrimSpace(body.Username),
|
||||
"permissions": permissions,
|
||||
"enabled": enabled,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.createAdminConsoleUser(r.Context(), body.Username, body.Password, permissions, enabled)
|
||||
if err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: "create-admin-operator"}, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: "create-admin-operator",
|
||||
Status: "ok",
|
||||
Message: fmt.Sprintf("Created operator %q.", user.Username),
|
||||
Details: map[string]any{"id": user.ID, "username": user.Username, "permissions": user.Permissions, "enabled": user.Enabled},
|
||||
})
|
||||
}
|
||||
|
||||
// handleUpdateAdminUserAPI changes rights and/or enabled state, dry run first.
|
||||
func (s *server) handleUpdateAdminUserAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body adminUserActionRequest
|
||||
if !s.decodeAdminUserAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "admin-operator-access")
|
||||
const action = "set-admin-operator-access"
|
||||
enabled := body.Enabled == nil || *body.Enabled
|
||||
permissions := normalisePermissions(body.Permissions)
|
||||
|
||||
if body.ID <= 0 {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, errAdminUserNotFound)
|
||||
return
|
||||
}
|
||||
if err := s.guardManagerRemoval(r.Context(), body.ID, permissions, enabled); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
|
||||
return
|
||||
}
|
||||
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: "ok",
|
||||
DryRun: true,
|
||||
Message: fmt.Sprintf("Would set operator #%d to %d permission(s), %s. Takes effect on their next request.",
|
||||
body.ID, len(permissions), enabledWord(enabled)),
|
||||
Details: map[string]any{"id": body.ID, "permissions": permissions, "enabled": enabled},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.updateAdminConsoleUser(r.Context(), body.ID, permissions, enabled)
|
||||
if err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: "ok",
|
||||
Message: fmt.Sprintf("Updated %q. The new access applies from their next request.", user.Username),
|
||||
Details: map[string]any{"id": user.ID, "username": user.Username, "permissions": user.Permissions, "enabled": user.Enabled},
|
||||
})
|
||||
}
|
||||
|
||||
// handleSetAdminUserPasswordAPI resets a password, dry run first.
|
||||
func (s *server) handleSetAdminUserPasswordAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body adminUserActionRequest
|
||||
if !s.decodeAdminUserAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
meta := s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "admin-operator-password")
|
||||
const action = "set-admin-operator-password"
|
||||
|
||||
if body.ID <= 0 {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, errAdminUserNotFound)
|
||||
return
|
||||
}
|
||||
if err := validateAdminPassword(body.Password); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
|
||||
return
|
||||
}
|
||||
|
||||
if meta.DryRun {
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: "ok",
|
||||
DryRun: true,
|
||||
Message: fmt.Sprintf("Would set a new password for operator #%d. Their existing sessions would be signed out.", body.ID),
|
||||
// The password itself is never echoed, not even back to the
|
||||
// operator who just typed it.
|
||||
Details: map[string]any{"id": body.ID},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.setAdminConsoleUserPassword(r.Context(), body.ID, body.Password); err != nil {
|
||||
writeCommandResultAPI(w, admin.CommandResult{CommandID: meta.CommandID, Action: action}, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, admin.CommandResult{
|
||||
CommandID: meta.CommandID,
|
||||
Action: action,
|
||||
Status: "ok",
|
||||
Message: fmt.Sprintf("Password changed for operator #%d. Their existing sessions are signed out.", body.ID),
|
||||
Details: map[string]any{"id": body.ID},
|
||||
})
|
||||
}
|
||||
|
||||
// decodeAdminUserAction shares the store check, body decode and reason
|
||||
// requirement across the three mutations.
|
||||
func (s *server) decodeAdminUserAction(w http.ResponseWriter, r *http.Request, body *adminUserActionRequest) bool {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return false
|
||||
}
|
||||
if err := decodeJSON(r, body); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(body.Reason) == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "a reason is required")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func enabledWord(enabled bool) string {
|
||||
if enabled {
|
||||
return "enabled"
|
||||
}
|
||||
return "disabled"
|
||||
}
|
||||
|
||||
// guardManagerRemoval refuses an edit that would leave nobody able to manage
|
||||
// operators. Counted over the other accounts, so demoting or disabling the
|
||||
// only remaining manager is what trips it.
|
||||
func (s *server) guardManagerRemoval(ctx context.Context, id int64, permissions []string, enabled bool) error {
|
||||
stillManages := enabled && newPanelPermissions(permissions).Has(permissionAdminsManage)
|
||||
if stillManages {
|
||||
return nil
|
||||
}
|
||||
others, err := s.read.CountEnabledAdminConsoleUsersWith(ctx, permissionAdminsManage, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if others == 0 {
|
||||
return errLastManagerStanding
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue