server admin panel is now supports multiple operators profiles

This commit is contained in:
onysd 2026-09-08 01:01:35 +03:00
parent e48160ac3a
commit 280321b902
25 changed files with 2197 additions and 128 deletions

View file

@ -0,0 +1,154 @@
package main
import (
"context"
"errors"
"strings"
"unicode"
"golang.org/x/crypto/bcrypt"
)
// bcryptCost is deliberately above bcrypt.DefaultCost (10). A panel login is a
// once-per-shift operation, so the extra time is invisible to an operator and
// meaningful to anyone working through a stolen dump of the table.
const bcryptCost = 12
// dummyBcryptHash is compared against when no account matched, so a login
// attempt costs the same whether or not the username exists. Without it the
// response time alone answers "is there an operator called X" -- the exact
// question the uniform error message refuses to answer.
//
// Value is bcrypt of a random string at bcryptCost; nothing authenticates
// against it.
const dummyBcryptHash = "$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// breakGlassUsername is the name of the built-in operator backed by
// TELESRV_ADMIN_UI_PASSWORD / _TOKEN rather than by a database row.
//
// It is a real name rather than "no name" so audit lines read as an operator
// instead of as a blank, and so signing in as it is an explicit act: a blank
// username authenticates nothing.
//
// A database account may not take this name: authenticateLogin resolves it to
// the environment credential before ever consulting the table, so a row called
// "owpengram" would be shadowed -- and a name that silently does nothing is a
// trap. createAdminConsoleUser rejects it outright.
const breakGlassUsername = "owpengram"
// loginIdentity is who a successful login turns out to be.
type loginIdentity struct {
actor string
userID int64
epoch int32
permissions []string
}
// authenticateLogin resolves a login request to an identity, or reports
// failure. It never distinguishes its failure modes to the caller: every one
// of them is a plain false, so the handler cannot accidentally leak which.
func (s *server) authenticateLogin(ctx context.Context, req loginRequest) (loginIdentity, bool) {
username := strings.TrimSpace(req.Username)
// A username is always required. An empty one used to resolve to the
// break-glass operator, which made a blank field an unnamed second route to
// the highest-privilege login -- the sort of thing that does not belong in
// an admin panel. The operator must now be asked for by name.
if username == "" {
return loginIdentity{}, false
}
// The break-glass operator. Intentionally not backed by the database so it
// still works when the database does not.
if strings.EqualFold(username, breakGlassUsername) {
if !s.validSecret(req.Secret) {
return loginIdentity{}, false
}
return loginIdentity{actor: breakGlassUsername, permissions: s.cfg.Permissions}, true
}
if s.read == nil {
return loginIdentity{}, false
}
cred, err := s.read.AdminConsoleCredentialByUsername(ctx, username)
if err != nil {
if !errors.Is(err, errAdminUserNotFound) {
return loginIdentity{}, false
}
// Burn the same work an existing account would have cost before
// answering, so "no such user" and "wrong password" take equal time.
_ = bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(req.Secret))
return loginIdentity{}, false
}
if bcrypt.CompareHashAndPassword([]byte(cred.PasswordHash), []byte(req.Secret)) != nil {
return loginIdentity{}, false
}
// Checked after the hash comparison on purpose: answering "disabled"
// faster than "wrong password" would confirm the account exists to someone
// who does not know its password.
if !cred.Enabled {
return loginIdentity{}, false
}
return loginIdentity{
actor: cred.Username,
userID: cred.ID,
epoch: cred.TokenEpoch,
permissions: cred.Permissions,
}, true
}
// hashAdminPassword validates a new password and returns its bcrypt hash.
func hashAdminPassword(password string) (string, error) {
if err := validateAdminPassword(password); err != nil {
return "", err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// validateAdminPassword deliberately imposes no length floor and no
// composition rule: the operator picks the password.
//
// The two checks that remain are not policy. A blank password is not a weak
// password, it is no password -- anyone who learns the username is in. And
// bcrypt silently ignores everything past 72 bytes, so a longer one is refused
// rather than quietly truncated to something the operator did not choose and
// cannot reproduce.
func validateAdminPassword(password string) error {
if strings.TrimSpace(password) == "" {
return errPasswordBlank
}
if len([]byte(password)) > 72 {
return errPasswordTooLong
}
return nil
}
var (
errPasswordTooLong = errors.New("password must be at most 72 bytes")
errPasswordBlank = errors.New("password must not be blank")
errUsernameInvalid = errors.New("username must be 3-64 characters: letters, digits, dot, dash or underscore")
)
// validateAdminUsername keeps usernames to a shape that reads the same
// everywhere it is displayed. Anything outside it -- spaces, control
// characters, look-alike unicode -- is refused rather than normalised, since a
// username that renders differently from what is stored is a way to be
// mistaken for another operator.
func validateAdminUsername(username string) error {
if n := len([]rune(username)); n < 3 || n > 64 {
return errUsernameInvalid
}
for _, r := range username {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', unicode.IsDigit(r):
case r == '.', r == '-', r == '_':
default:
return errUsernameInvalid
}
}
return nil
}

View file

@ -0,0 +1,237 @@
package main
import (
"errors"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestValidateAdminPassword(t *testing.T) {
cases := []struct {
name string
password string
want error
}{
{"ok", "correct horse battery", nil},
// No length floor: the operator picks the password, however short.
{"a single character", "x", nil},
{"blank", " ", errPasswordBlank},
{"empty", "", errPasswordBlank},
// bcrypt truncates silently past 72 bytes, so anything longer must be
// refused rather than accepted as a password the operator did not set.
{"past bcrypt's input limit", strings.Repeat("a", 73), errPasswordTooLong},
{"73 bytes of multibyte runes", strings.Repeat("é", 37), errPasswordTooLong},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateAdminPassword(tc.password)
if !errors.Is(err, tc.want) {
t.Fatalf("validateAdminPassword(%q) = %v, want %v", tc.password, err, tc.want)
}
})
}
}
func TestValidateAdminUsername(t *testing.T) {
valid := []string{"admin", "ops.lead", "on-call_2", strings.Repeat("a", 64)}
for _, username := range valid {
if err := validateAdminUsername(username); err != nil {
t.Errorf("validateAdminUsername(%q) = %v, want nil", username, err)
}
}
invalid := []string{
"",
"ab", // under the floor
strings.Repeat("a", 65), // over the ceiling
"has space",
"with\ttab",
"with\nnewline",
"аdmin", // Cyrillic 'а': renders like "admin" but is a different operator
"admin*", // permission wildcard has no business in a name
"a@b",
}
for _, username := range invalid {
if err := validateAdminUsername(username); !errors.Is(err, errUsernameInvalid) {
t.Errorf("validateAdminUsername(%q) = %v, want errUsernameInvalid", username, err)
}
}
}
func TestHashAdminPasswordRoundTrips(t *testing.T) {
const password = "a sufficiently long password"
hash, err := hashAdminPassword(password)
if err != nil {
t.Fatalf("hashAdminPassword: %v", err)
}
if strings.Contains(hash, password) {
t.Fatal("hash contains the plaintext")
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
t.Fatalf("hash does not verify against its own password: %v", err)
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+"x")); err == nil {
t.Fatal("hash verified against the wrong password")
}
if cost, err := bcrypt.Cost([]byte(hash)); err != nil || cost != bcryptCost {
t.Fatalf("cost = %d (err %v), want %d", cost, err, bcryptCost)
}
}
func TestHashAdminPasswordRejectsInvalid(t *testing.T) {
// A short password is fine; an absent one is not.
if _, err := hashAdminPassword("x"); err != nil {
t.Fatalf("a one-character password was refused: %v", err)
}
if _, err := hashAdminPassword(" "); !errors.Is(err, errPasswordBlank) {
t.Fatalf("err = %v, want errPasswordBlank", err)
}
if _, err := hashAdminPassword(strings.Repeat("a", 73)); !errors.Is(err, errPasswordTooLong) {
t.Fatalf("err = %v, want errPasswordTooLong", err)
}
}
// The dummy hash exists so a login against an unknown username costs the same
// bcrypt work as a real one. If it were malformed, CompareHashAndPassword would
// return early and hand back the timing signal it is there to remove.
func TestDummyBcryptHashIsWellFormedAndUnusable(t *testing.T) {
cost, err := bcrypt.Cost([]byte(dummyBcryptHash))
if err != nil {
t.Fatalf("dummy hash is not a valid bcrypt hash: %v", err)
}
if cost != bcryptCost {
t.Fatalf("dummy hash cost = %d, want %d -- it must cost the same as a real one", cost, bcryptCost)
}
for _, guess := range []string{"", "password", "admin", dummyBcryptHash} {
if err := bcrypt.CompareHashAndPassword([]byte(dummyBcryptHash), []byte(guess)); err == nil {
t.Fatalf("dummy hash authenticated %q", guess)
}
}
}
func TestNormalisePermissions(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{"trims and drops empties", []string{" a ", "", " ", "b"}, []string{"a", "b"}},
{"de-duplicates", []string{"a", "a", "b", "a"}, []string{"a", "b"}},
// A stored list that both names the wildcard and lists rights would read
// narrower than it actually is wherever it is displayed.
{"wildcard collapses everything", []string{"a", "*", "b"}, []string{permissionAll}},
{"wildcard alone", []string{"*"}, []string{permissionAll}},
{"empty stays empty", []string{}, []string{}},
{"only blanks", []string{"", " "}, []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := normalisePermissions(tc.in)
if len(got) != len(tc.want) {
t.Fatalf("normalisePermissions(%v) = %v, want %v", tc.in, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("normalisePermissions(%v) = %v, want %v", tc.in, got, tc.want)
}
}
})
}
}
// assignablePermissions drives the account editor. Offering "*" there would let
// a click hand out every right including admins.manage, which is exactly what
// the per-permission list exists to make deliberate.
func TestAssignablePermissionsExcludesWildcard(t *testing.T) {
for _, p := range assignablePermissions() {
if p == permissionAll {
t.Fatal("assignablePermissions offers the wildcard")
}
}
var sawAdminsManage bool
for _, p := range assignablePermissions() {
if p == permissionAdminsManage {
sawAdminsManage = true
}
}
if !sawAdminsManage {
t.Fatal("assignablePermissions omits admins.manage, so it could never be granted")
}
}
// A blank username must never authenticate, even with the correct break-glass
// secret. It briefly did, which made an empty field an unnamed second route to
// the highest-privilege login; the operator has to be asked for by name.
func TestBlankUsernameNeverAuthenticates(t *testing.T) {
s := &server{cfg: uiConfig{Password: "letmein", Permissions: []string{permissionAll}}}
for _, username := range []string{"", " ", "\t"} {
if _, ok := s.authenticateLogin(t.Context(), loginRequest{Username: username, Secret: "letmein"}); ok {
t.Fatalf("blank username %q authenticated", username)
}
}
// The same secret under the operator's actual name still works, so the
// check above is refusing the blank name rather than the credential.
identity, ok := s.authenticateLogin(t.Context(), loginRequest{Username: breakGlassUsername, Secret: "letmein"})
if !ok {
t.Fatal("the break-glass operator could not sign in by name")
}
if identity.actor != breakGlassUsername {
t.Fatalf("actor = %q, want %q", identity.actor, breakGlassUsername)
}
if identity.userID != 0 {
t.Fatalf("userID = %d, want 0 -- the break-glass operator has no database row", identity.userID)
}
}
// Case is not a way to get a different operator: the name resolves to the
// break-glass login however it is typed, matching the case-insensitive unique
// index that named accounts live under.
func TestBreakGlassUsernameIsCaseInsensitive(t *testing.T) {
s := &server{cfg: uiConfig{Password: "letmein", Permissions: []string{permissionAll}}}
for _, username := range []string{"owpengram", "OwpenGram", "OWPENGRAM", " owpengram "} {
identity, ok := s.authenticateLogin(t.Context(), loginRequest{Username: username, Secret: "letmein"})
if !ok {
t.Fatalf("%q did not resolve to the break-glass operator", username)
}
if identity.actor != breakGlassUsername {
t.Fatalf("%q signed in as %q", username, identity.actor)
}
}
if _, ok := s.authenticateLogin(t.Context(), loginRequest{Username: breakGlassUsername, Secret: "wrong"}); ok {
t.Fatal("the break-glass operator authenticated with the wrong secret")
}
}
// A session for a named account must not be trusted on the strength of its
// signature alone: the account's rights are re-read per request, and a nil read
// store has to fail closed rather than fall back to the claims.
func TestCurrentSessionPermissionsFailsClosedWithoutStore(t *testing.T) {
s := &server{}
if _, ok := s.currentSessionPermissions(t.Context(), sessionClaims{
UserID: 7,
Epoch: 1,
Permissions: []string{permissionAll},
}); ok {
t.Fatal("a named-account session was accepted with no store to verify it against")
}
}
// The break-glass operator has no row to re-read, so it keeps the configured
// rights -- that login is the way back in when the database is unreachable.
func TestCurrentSessionPermissionsAllowsBreakGlass(t *testing.T) {
s := &server{}
perms, ok := s.currentSessionPermissions(t.Context(), sessionClaims{
UserID: 0,
Permissions: []string{permissionServerManage},
})
if !ok {
t.Fatal("break-glass session rejected")
}
if !perms.Has(permissionServerManage) {
t.Fatal("break-glass session lost its configured permission")
}
if perms.Has(permissionAdminsManage) {
t.Fatal("break-glass session gained a permission it was not configured with")
}
}

View file

@ -0,0 +1,125 @@
package main
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
// AdminConsoleUser is one named panel operator. It deliberately never carries
// the password hash outside authentication: everything that renders or returns
// a user uses this shape, so a hash cannot leak into an API response by
// someone adding a field to a JSON struct.
type AdminConsoleUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
TokenEpoch int32 `json:"token_epoch"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
// adminConsoleCredential is the authentication-only view: the hash plus the
// few fields a login decision needs. Kept unexported and separate from
// AdminConsoleUser so the hash has exactly one reason to be read.
type adminConsoleCredential struct {
ID int64
Username string
PasswordHash string
Permissions []string
Enabled bool
TokenEpoch int32
}
// errAdminUserNotFound is returned instead of pgx.ErrNoRows so callers can
// treat "no such operator" without importing pgx.
var errAdminUserNotFound = errors.New("admin console user not found")
const adminConsoleUserColumns = `id, username, permissions, enabled, token_epoch, created_at, updated_at, last_login_at`
// AdminConsoleCredentialByUsername loads the authentication view for a login
// attempt. The lookup is case-insensitive to match the unique index, so an
// operator cannot be shadowed by a differently-cased duplicate.
func (s *readStore) AdminConsoleCredentialByUsername(ctx context.Context, username string) (adminConsoleCredential, error) {
var out adminConsoleCredential
err := s.pool.QueryRow(ctx, `
SELECT id, username, password_hash, permissions, enabled, token_epoch
FROM admin_console_users
WHERE lower(username) = lower($1)`, strings.TrimSpace(username)).Scan(
&out.ID, &out.Username, &out.PasswordHash, &out.Permissions, &out.Enabled, &out.TokenEpoch)
if errors.Is(err, pgx.ErrNoRows) {
return adminConsoleCredential{}, errAdminUserNotFound
}
if err != nil {
return adminConsoleCredential{}, fmt.Errorf("load admin console credential: %w", err)
}
return out, nil
}
// AdminConsoleSessionState re-reads the two things a live session depends on.
// requireAuthAPI calls it per request so that disabling an operator, editing
// their rights or changing their password takes effect immediately rather than
// whenever their signed cookie happens to expire.
func (s *readStore) AdminConsoleSessionState(ctx context.Context, id int64) (enabled bool, epoch int32, permissions []string, err error) {
err = s.pool.QueryRow(ctx, `
SELECT enabled, token_epoch, permissions FROM admin_console_users WHERE id = $1`, id).
Scan(&enabled, &epoch, &permissions)
if errors.Is(err, pgx.ErrNoRows) {
return false, 0, nil, errAdminUserNotFound
}
if err != nil {
return false, 0, nil, fmt.Errorf("load admin console session state: %w", err)
}
return enabled, epoch, permissions, nil
}
// ListAdminConsoleUsers returns every operator, newest last so the list reads
// like the order they were added.
func (s *readStore) ListAdminConsoleUsers(ctx context.Context) ([]AdminConsoleUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT `+adminConsoleUserColumns+` FROM admin_console_users ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list admin console users: %w", err)
}
defer rows.Close()
out := []AdminConsoleUser{}
for rows.Next() {
var u AdminConsoleUser
if err := rows.Scan(&u.ID, &u.Username, &u.Permissions, &u.Enabled,
&u.TokenEpoch, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt); err != nil {
return nil, fmt.Errorf("scan admin console user: %w", err)
}
if u.Permissions == nil {
u.Permissions = []string{}
}
out = append(out, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate admin console users: %w", err)
}
return out, nil
}
// CountEnabledAdminConsoleUsersWith reports how many enabled operators hold a
// given permission, counting the '*' wildcard as holding everything. It exists
// for the last-administrator guard: the panel refuses the edit that would
// leave nobody able to manage operators.
func (s *readStore) CountEnabledAdminConsoleUsersWith(ctx context.Context, permission string, excludeID int64) (int, error) {
var n int
if err := s.pool.QueryRow(ctx, `
SELECT count(*)::int FROM admin_console_users
WHERE enabled
AND id <> $2
AND (permissions @> ARRAY[$1]::text[] OR permissions @> ARRAY['*']::text[])`,
permission, excludeID).Scan(&n); err != nil {
return 0, fmt.Errorf("count admin console users with permission: %w", err)
}
return n, nil
}

View 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
}

View file

@ -0,0 +1,128 @@
package main
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// The panel is deny-by-default: an API route must say which right it belongs
// to. Registering one with a bare requireAuthAPI would make it answer to every
// signed-in operator regardless of what they were granted -- which is how a
// scoped account quietly gets the run of the place.
//
// This reads the source rather than the routing table because that is where the
// mistake is made: it fails on the line someone is about to add, and names it.
func TestEveryAPIRouteDeclaresAScope(t *testing.T) {
// Every /api route must be registered through a wrapper that names a
// permission. Whitelisting the wrappers rather than blacklisting the bare
// one is what makes this hold for helpers added later: a new wrapper is
// unknown here until someone adds it deliberately, so it fails closed.
allowed := []string{
"s.scopedRoute(",
"s.scopedRouteAll(",
"s.requirePermission(",
"s.requireAdminsManage(",
"s.serverManage(",
"s.verificationRead(",
"s.botVerificationRead(",
"s.botVerificationManage(",
}
// /api/login is the way in, so it is authenticated by the credential it
// carries rather than by a session that does not exist yet.
exempt := map[string]bool{"POST /api/login": true}
route := regexp.MustCompile(`mux\.Handle(Func)?\("([A-Z]+ /api/[^"]*)"`)
entries, err := os.ReadDir(".")
if err != nil {
t.Fatalf("read package directory: %v", err)
}
var offenders []string
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
source, err := os.ReadFile(filepath.Clean(name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
for _, line := range strings.Split(string(source), "\n") {
m := route.FindStringSubmatch(line)
if m == nil || exempt[m[2]] {
continue
}
guarded := false
for _, wrapper := range allowed {
if strings.Contains(line, wrapper) {
guarded = true
break
}
}
if !guarded {
offenders = append(offenders, name+": "+strings.TrimSpace(line))
}
}
}
if len(offenders) > 0 {
t.Fatalf("these routes are registered without a permission -- wrap them in s.scopedRoute(permission, ...):\n %s",
strings.Join(offenders, "\n "))
}
}
// Every right the account editor offers must be one the routes actually check,
// and vice versa -- a name in one list and not the other is either a right
// nobody can be granted or a checkbox that grants nothing.
func TestAssignablePermissionsMatchWhatRoutesEnforce(t *testing.T) {
assignable := make(map[string]bool, len(assignablePermissions()))
for _, p := range assignablePermissions() {
if p == permissionSessionOnly {
t.Fatal("permissionSessionOnly is not a grantable right and must not be offered")
}
if assignable[p] {
t.Fatalf("permission %q is offered twice", p)
}
assignable[p] = true
}
if len(assignable) == 0 {
t.Fatal("no assignable permissions")
}
// Spot-check the pairs the sections are built around, so a rename that
// misses one half is caught here rather than by an operator who suddenly
// cannot open a page.
for _, required := range []string{
permissionAccountsRead, permissionAccountsManage,
permissionChannelsRead, permissionChannelsManage,
permissionBotsRead, permissionBotsManage,
permissionMessagesRead, permissionMessagesManage,
permissionContentRead, permissionContentManage,
permissionUsernamesRead, permissionUsernamesManage,
permissionStorageRead, permissionStorageManage,
permissionBroadcastsRead, permissionBroadcastsSend,
permissionModerationReview, permissionDashboardRead,
permissionAdminsManage, permissionServerManage,
} {
if !assignable[required] {
t.Errorf("permission %q is enforced somewhere but cannot be granted", required)
}
}
}
// permissionSessionOnly must stay the empty string: scopedRoute distinguishes
// "a session is enough" from a real right by that emptiness, and panelPermissions
// drops empty entries, so it can never be smuggled into an account's list.
func TestSessionOnlyIsNotGrantable(t *testing.T) {
if permissionSessionOnly != "" {
t.Fatalf("permissionSessionOnly = %q, want the empty string", permissionSessionOnly)
}
perms := newPanelPermissions([]string{permissionSessionOnly, permissionAccountsRead})
if perms.Has(permissionSessionOnly) {
t.Fatal("an empty permission was treated as granted")
}
if !perms.Has(permissionAccountsRead) {
t.Fatal("a real permission alongside it was lost")
}
}

View file

@ -54,8 +54,115 @@ const (
// git/go and bounces the live MTProto process), so it is one right, not
// split into review/manage like the sections above.
permissionServerManage = "server.manage"
// permissionAdminsManage gates the operator accounts themselves: creating
// them, editing their rights, disabling them, resetting their passwords.
//
// It is the one right that can grant every other right, so it is never
// implied by anything else and is worth handing out to far fewer people
// than server.manage. guardManagerRemoval additionally refuses the edit
// that would leave nobody holding it.
permissionAdminsManage = "admins.manage"
// Section rights, in read/manage pairs that follow the sidebar. Reading a
// section and changing it are separate grants because most of the people
// who need to look at this data never need to alter it.
permissionAccountsRead = "accounts.read"
permissionAccountsManage = "accounts.manage"
permissionChannelsRead = "channels.read"
permissionChannelsManage = "channels.manage"
permissionBotsRead = "bots.read"
permissionBotsManage = "bots.manage"
permissionMessagesRead = "messages.read"
permissionMessagesManage = "messages.manage"
permissionModerationReview = "moderation.review"
permissionBroadcastsRead = "broadcasts.read"
permissionBroadcastsSend = "broadcasts.send"
permissionStorageRead = "storage.read"
permissionStorageManage = "storage.manage"
// Sticker packs, emoji packs and the GIF catalogue: one section as far as
// the panel is concerned, so one pair of rights.
permissionContentRead = "content.read"
permissionContentManage = "content.manage"
permissionUsernamesRead = "usernames.read"
permissionUsernamesManage = "usernames.manage"
permissionDashboardRead = "dashboard.read"
// permissionSessionOnly marks the handful of routes that need a session but
// no right: reading who you are, and signing out. It is not a grantable
// name -- scopedRoute treats it as "authenticated is enough" -- so it can
// never be typed into an account's permission list by mistake.
permissionSessionOnly = ""
)
// assignablePermissions is the vocabulary the operator-accounts screen offers.
//
// The wildcard is deliberately absent: it is meaningful in
// TELESRV_ADMIN_UI_PERMISSIONS for the break-glass login, but handing "*" to a
// named account through a UI is how least privilege quietly stops being a
// thing. An operator who genuinely needs everything gets every entry ticked,
// which at least leaves a legible record of what was granted.
func assignablePermissions() []string {
return []string{
permissionAccountsRead,
permissionAccountsManage,
permissionChannelsRead,
permissionChannelsManage,
permissionBotsRead,
permissionBotsManage,
permissionMessagesRead,
permissionMessagesManage,
permissionModerationReview,
permissionBroadcastsRead,
permissionBroadcastsSend,
permissionContentRead,
permissionContentManage,
permissionUsernamesRead,
permissionUsernamesManage,
permissionStorageRead,
permissionStorageManage,
permissionDashboardRead,
permissionPremiumManage,
permissionBotTokenRead,
permissionVerificationReview,
permissionVerificationRevoke,
permissionBotVerificationReview,
permissionBotVerificationManage,
permissionServerManage,
permissionAdminsManage,
}
}
// scopedRoute is the only way an API route should be registered. Requiring the
// permission as an argument is what makes the panel deny-by-default: a route
// cannot be added without someone stating which right it belongs to, so the
// failure mode of forgetting is a compile error rather than an endpoint that
// quietly answers to everyone.
//
// permissionSessionOnly is the deliberate exception, spelled out at each use.
func (s *server) scopedRoute(permission string, handler http.Handler) http.Handler {
if permission == permissionSessionOnly {
return s.requireAuthAPI(handler)
}
return s.requireAuthAPI(s.requirePermission(permission, handler))
}
// scopedRouteAll is scopedRoute for a route that needs more than one right at
// once -- taking a granted verification badge away needs both the right to work
// the queue and the separate right to revoke. Every permission must be held;
// they are requirements, not alternatives.
func (s *server) scopedRouteAll(permissions []string, handler http.Handler) http.Handler {
if len(permissions) == 0 {
// Refusing outright beats silently degrading to "any session": an empty
// list here is a mistake at the call site, not a way to open a route.
panic("scopedRouteAll: no permissions given")
}
wrapped := handler
for i := len(permissions) - 1; i >= 0; i-- {
wrapped = s.requirePermission(permissions[i], wrapped)
}
return s.requireAuthAPI(wrapped)
}
type permissionsKey struct{}
// requireAuthAPI is the gate on every authenticated API route: a valid session,
@ -76,12 +183,46 @@ func (s *server) requireAuthAPI(next http.Handler) http.Handler {
if !checkMutationSafety(w, r, claims) {
return
}
// Rights inside the cookie are a 12-hour snapshot; the account they
// belong to may have been disabled, demoted or had its password changed
// since. Re-read it and use what the database says now, so revocation
// takes effect on the next request rather than at session expiry.
permissions, ok := s.currentSessionPermissions(r.Context(), claims)
if !ok {
clearSessionCookie(w)
writeAPIError(w, http.StatusUnauthorized, "session is no longer valid")
return
}
ctx := context.WithValue(r.Context(), actorKey{}, claims.Actor)
ctx = context.WithValue(ctx, permissionsKey{}, newPanelPermissions(claims.Permissions))
ctx = context.WithValue(ctx, permissionsKey{}, permissions)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// currentSessionPermissions resolves the rights this request actually gets.
//
// The break-glass operator (UserID 0) has no database row and keeps the
// configured set -- that login exists precisely for when the database cannot
// be consulted, so it must not depend on one.
//
// A named account is re-read every request. Anything that moved its token
// epoch invalidates the session; anything that narrowed its permissions
// narrows this request. A read failure is treated as a refusal rather than as
// permission, so a database outage cannot silently widen access.
func (s *server) currentSessionPermissions(ctx context.Context, claims sessionClaims) (panelPermissions, bool) {
if claims.UserID == 0 {
return newPanelPermissions(claims.Permissions), true
}
if s.read == nil {
return panelPermissions{}, false
}
enabled, epoch, permissions, err := s.read.AdminConsoleSessionState(ctx, claims.UserID)
if err != nil || !enabled || epoch != claims.Epoch {
return panelPermissions{}, false
}
return newPanelPermissions(permissions), true
}
// requirePermission refuses a session that was not granted the right, before the
// request ever reaches the admin API. The panel is the only caller that can be
// driven by a browser, so the check belongs here as well as upstream: a 403 from

View file

@ -63,86 +63,96 @@ func (s *server) routes() http.Handler {
// Logout goes through the same gate as every other mutating route: a forced
// logout is a state change, and an invalid session is cleared by the gate
// itself, so nothing is stranded by protecting it.
mux.Handle("POST /api/logout", s.requireAuthAPI(http.HandlerFunc(s.handleAPILogout)))
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/dashboard", s.requireAuthAPI(http.HandlerFunc(s.handleDashboardAPI)))
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
mux.Handle("GET /api/accounts/shared-devices", s.requireAuthAPI(http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
mux.Handle("GET /api/broadcasts", s.requireAuthAPI(http.HandlerFunc(s.handleBroadcastsAPI)))
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
mux.Handle("GET /api/accounts/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/channels", s.requireAuthAPI(http.HandlerFunc(s.handleChannelsAPI)))
mux.Handle("GET /api/channels/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleChannelDetailAPI)))
mux.Handle("GET /api/channels/{id}/avatar", s.requireAuthAPI(http.HandlerFunc(s.handleChannelAvatarAPI)))
mux.Handle("GET /api/bots", s.requireAuthAPI(http.HandlerFunc(s.handleBotsAPI)))
mux.Handle("GET /api/bots/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleBotDetailAPI)))
mux.Handle("GET /api/emoji", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAPI)))
mux.Handle("GET /api/emoji/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleEmojiAnimationAPI)))
mux.Handle("GET /api/messages", s.requireAuthAPI(http.HandlerFunc(s.handleMessagesAPI)))
mux.Handle("GET /api/messages/detail", s.requireAuthAPI(http.HandlerFunc(s.handleMessageDetailAPI)))
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
mux.Handle("GET /api/moderation/cases", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCasesAPI)))
mux.Handle("GET /api/moderation/cases/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCaseAPI)))
mux.Handle("GET /api/moderation/reports/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationReportAPI)))
mux.Handle("POST /api/moderation/cases/{id}/claim", s.requireAuthAPI(http.HandlerFunc(s.handleClaimModerationCaseAPI)))
mux.Handle("POST /api/moderation/cases/{id}/decide", s.requireAuthAPI(http.HandlerFunc(s.handleDecideModerationCaseAPI)))
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.requireAuthAPI(http.HandlerFunc(s.handleReviewModerationAppealAPI)))
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/set-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetVerifiedAPI)))
mux.Handle("POST /api/actions/set-account-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserFlagsAPI)))
mux.Handle("POST /api/actions/set-channel-flags", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelFlagsAPI)))
mux.Handle("POST /api/actions/set-support", s.requireAuthAPI(http.HandlerFunc(s.handleSetSupportAPI)))
mux.Handle("POST /api/actions/set-account-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetUsernameAPI)))
mux.Handle("POST /api/actions/set-account-profile", s.requireAuthAPI(http.HandlerFunc(s.handleSetProfileAPI)))
mux.Handle("POST /api/actions/set-account-phone", s.requireAuthAPI(http.HandlerFunc(s.handleSetPhoneAPI)))
mux.Handle("POST /api/actions/set-account-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountAvatarAPI)))
mux.Handle("POST /api/actions/set-account-login-email", s.requireAuthAPI(http.HandlerFunc(s.handleSetLoginEmailAPI)))
mux.Handle("POST /api/actions/set-account-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserColorAPI)))
mux.Handle("POST /api/actions/set-account-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
mux.Handle("POST /api/actions/set-channel-avatar", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelAvatarAPI)))
mux.Handle("POST /api/actions/set-channel-settings", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelSettingsAPI)))
mux.Handle("POST /api/actions/set-channel-username", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelUsernameAPI)))
mux.Handle("POST /api/actions/set-channel-color", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelColorAPI)))
mux.Handle("POST /api/actions/set-channel-emoji-status", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
mux.Handle("POST /api/actions/create-bot", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("POST /api/actions/create-broadcast", s.requireAuthAPI(http.HandlerFunc(s.handleCreateBroadcastAPI)))
mux.Handle("POST /api/actions/delete-bot", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.requireAuthAPI(s.requirePermission(permissionBotTokenRead, http.HandlerFunc(s.handleExportBotTokenAPI))))
mux.Handle("POST /api/actions/set-channel-verified", s.requireAuthAPI(http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/delete-messages", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteMessagesAPI)))
mux.Handle("POST /api/actions/delete-history", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteHistoryAPI)))
mux.Handle("GET /api/stickers", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetsAPI)))
mux.Handle("GET /api/stickers/{id}/documents", s.requireAuthAPI(http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
mux.Handle("GET /api/stickers/documents/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
mux.Handle("GET /api/gif-catalog/documents/{id}/preview", s.requireAuthAPI(http.HandlerFunc(s.handleGifCatalogDocumentPreviewAPI)))
mux.Handle("POST /api/actions/set-sticker-set-archived", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetArchivedAPI)))
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
mux.Handle("POST /api/actions/rename-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleRenameStickerSetAPI)))
mux.Handle("POST /api/actions/delete-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteStickerSetAPI)))
mux.Handle("POST /api/actions/create-sticker-set", s.requireAuthAPI(http.HandlerFunc(s.handleCreateStickerSetAPI)))
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
mux.Handle("GET /api/gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleGifCatalogAPI)))
mux.Handle("POST /api/actions/create-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleCreateGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogEnabledAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-category", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogCategoryAPI)))
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
mux.Handle("POST /api/actions/storage-manual-purge", s.requireAuthAPI(http.HandlerFunc(s.handleStorageManualPurgeAPI)))
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/delete-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
mux.Handle("POST /api/logout", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleAPILogout)))
mux.Handle("GET /api/session", s.scopedRoute(permissionSessionOnly, http.HandlerFunc(s.handleSession)))
// Operator accounts. Every one of these is gated on admins.manage -- the
// right that can hand out every other right -- so they are registered
// together rather than scattered among the domain routes.
mux.Handle("GET /api/admin-users", s.requireAdminsManage(http.HandlerFunc(s.handleListAdminUsersAPI)))
// Mutations live under /api/actions/* like every other command in the
// panel, so they get the same reason + dry-run + confirm flow.
mux.Handle("POST /api/actions/create-admin-operator", s.requireAdminsManage(http.HandlerFunc(s.handleCreateAdminUserAPI)))
mux.Handle("POST /api/actions/set-admin-operator-access", s.requireAdminsManage(http.HandlerFunc(s.handleUpdateAdminUserAPI)))
mux.Handle("POST /api/actions/set-admin-operator-password", s.requireAdminsManage(http.HandlerFunc(s.handleSetAdminUserPasswordAPI)))
mux.Handle("GET /api/dashboard", s.scopedRoute(permissionDashboardRead, http.HandlerFunc(s.handleDashboardAPI)))
mux.Handle("GET /api/accounts", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/stats", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountsStatsAPI)))
mux.Handle("GET /api/accounts/shared-devices", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleSharedDeviceGroupsAPI)))
mux.Handle("GET /api/broadcasts", s.scopedRoute(permissionBroadcastsRead, http.HandlerFunc(s.handleBroadcastsAPI)))
mux.Handle("GET /api/accounts/{id}", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountDetailAPI)))
mux.Handle("GET /api/accounts/{id}/avatar", s.scopedRoute(permissionAccountsRead, http.HandlerFunc(s.handleAccountAvatarAPI)))
mux.Handle("GET /api/channels", s.scopedRoute(permissionChannelsRead, http.HandlerFunc(s.handleChannelsAPI)))
mux.Handle("GET /api/channels/{id}", s.scopedRoute(permissionChannelsRead, http.HandlerFunc(s.handleChannelDetailAPI)))
mux.Handle("GET /api/channels/{id}/avatar", s.scopedRoute(permissionChannelsRead, http.HandlerFunc(s.handleChannelAvatarAPI)))
mux.Handle("GET /api/bots", s.scopedRoute(permissionBotsRead, http.HandlerFunc(s.handleBotsAPI)))
mux.Handle("GET /api/bots/{id}", s.scopedRoute(permissionBotsRead, http.HandlerFunc(s.handleBotDetailAPI)))
mux.Handle("GET /api/emoji", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleEmojiAPI)))
mux.Handle("GET /api/emoji/{id}/animation", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleEmojiAnimationAPI)))
mux.Handle("GET /api/messages", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleMessagesAPI)))
mux.Handle("GET /api/messages/detail", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleMessageDetailAPI)))
mux.Handle("GET /api/messages/groups", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("GET /api/collectible-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
mux.Handle("GET /api/collectible-usernames/{id}", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
mux.Handle("GET /api/storage/stats", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageStatsAPI)))
mux.Handle("GET /api/storage/accounts", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageAccountsAPI)))
mux.Handle("GET /api/moderation/cases", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleModerationCasesAPI)))
mux.Handle("GET /api/moderation/cases/{id}", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleModerationCaseAPI)))
mux.Handle("GET /api/moderation/reports/{id}", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleModerationReportAPI)))
mux.Handle("POST /api/moderation/cases/{id}/claim", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleClaimModerationCaseAPI)))
mux.Handle("POST /api/moderation/cases/{id}/decide", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleDecideModerationCaseAPI)))
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.scopedRoute(permissionModerationReview, http.HandlerFunc(s.handleReviewModerationAppealAPI)))
mux.Handle("POST /api/actions/set-frozen", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountFrozenAPI)))
mux.Handle("POST /api/actions/grant-premium", s.scopedRoute(permissionPremiumManage, http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/set-verified", s.scopedRoute(permissionVerificationReview, http.HandlerFunc(s.handleSetVerifiedAPI)))
mux.Handle("POST /api/actions/set-account-flags", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserFlagsAPI)))
mux.Handle("POST /api/actions/set-channel-flags", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelFlagsAPI)))
mux.Handle("POST /api/actions/set-support", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetSupportAPI)))
mux.Handle("POST /api/actions/set-account-username", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUsernameAPI)))
mux.Handle("POST /api/actions/set-account-profile", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetProfileAPI)))
mux.Handle("POST /api/actions/set-account-phone", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetPhoneAPI)))
mux.Handle("POST /api/actions/set-account-avatar", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetAccountAvatarAPI)))
mux.Handle("POST /api/actions/set-account-login-email", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetLoginEmailAPI)))
mux.Handle("POST /api/actions/set-account-color", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserColorAPI)))
mux.Handle("POST /api/actions/set-account-emoji-status", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleSetUserEmojiStatusAPI)))
mux.Handle("POST /api/actions/set-channel-avatar", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelAvatarAPI)))
mux.Handle("POST /api/actions/set-channel-settings", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelSettingsAPI)))
mux.Handle("POST /api/actions/set-channel-username", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelUsernameAPI)))
mux.Handle("POST /api/actions/set-channel-color", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelColorAPI)))
mux.Handle("POST /api/actions/set-channel-emoji-status", s.scopedRoute(permissionChannelsManage, http.HandlerFunc(s.handleSetChannelEmojiStatusAPI)))
mux.Handle("POST /api/actions/create-bot", s.scopedRoute(permissionBotsManage, http.HandlerFunc(s.handleCreateBotAPI)))
mux.Handle("POST /api/actions/create-broadcast", s.scopedRoute(permissionBroadcastsSend, http.HandlerFunc(s.handleCreateBroadcastAPI)))
mux.Handle("POST /api/actions/delete-bot", s.scopedRoute(permissionBotsManage, http.HandlerFunc(s.handleDeleteBotAPI)))
mux.Handle("POST /api/actions/export-bot-token", s.scopedRoute(permissionBotTokenRead, http.HandlerFunc(s.handleExportBotTokenAPI)))
mux.Handle("POST /api/actions/set-channel-verified", s.scopedRoute(permissionVerificationReview, http.HandlerFunc(s.handleSetChannelVerifiedAPI)))
mux.Handle("POST /api/actions/revoke-sessions", s.scopedRoute(permissionAccountsManage, http.HandlerFunc(s.handleRevokeSessionsAPI)))
mux.Handle("POST /api/actions/delete-messages", s.scopedRoute(permissionMessagesManage, http.HandlerFunc(s.handleDeleteMessagesAPI)))
mux.Handle("POST /api/actions/delete-history", s.scopedRoute(permissionMessagesManage, http.HandlerFunc(s.handleDeleteHistoryAPI)))
mux.Handle("GET /api/stickers", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleStickerSetsAPI)))
mux.Handle("GET /api/stickers/{id}/documents", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleStickerSetDocumentsAPI)))
mux.Handle("GET /api/stickers/documents/{id}/animation", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleStickerDocumentAnimationAPI)))
mux.Handle("GET /api/gif-catalog/documents/{id}/preview", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleGifCatalogDocumentPreviewAPI)))
mux.Handle("POST /api/actions/set-sticker-set-archived", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetStickerSetArchivedAPI)))
mux.Handle("POST /api/actions/set-sticker-set-sort-order", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetStickerSetSortOrderAPI)))
mux.Handle("POST /api/actions/rename-sticker-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleRenameStickerSetAPI)))
mux.Handle("POST /api/actions/delete-sticker-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteStickerSetAPI)))
mux.Handle("POST /api/actions/create-sticker-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleCreateStickerSetAPI)))
mux.Handle("POST /api/actions/add-sticker-to-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleAddStickerToSetAPI)))
mux.Handle("POST /api/actions/remove-sticker-from-set", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
mux.Handle("GET /api/gif-catalog", s.scopedRoute(permissionContentRead, http.HandlerFunc(s.handleGifCatalogAPI)))
mux.Handle("POST /api/actions/create-gif-catalog-entry", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleCreateGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetGifCatalogEnabledAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI)))
mux.Handle("POST /api/actions/set-gif-catalog-category", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleSetGifCatalogCategoryAPI)))
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
mux.Handle("POST /api/actions/storage-manual-purge", s.scopedRoute(permissionStorageManage, http.HandlerFunc(s.handleStorageManualPurgeAPI)))
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/mint-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/revoke-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/delete-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
// Official platform verification. Every route needs verification.review;
// clearing an existing badge needs verification.revoke on top of it.
mux.Handle("GET /api/verification/applications", s.verificationRead(s.handleVerificationApplicationsAPI))
@ -151,9 +161,9 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/verification/applications/{id}/claim", s.verificationRead(s.handleClaimVerificationAPI))
mux.Handle("POST /api/verification/applications/{id}/approve", s.verificationRead(s.handleApproveVerificationAPI))
mux.Handle("POST /api/verification/applications/{id}/reject", s.verificationRead(s.handleRejectVerificationAPI))
mux.Handle("POST /api/actions/revoke-verification", s.requireAuthAPI(
s.requirePermission(permissionVerificationReview,
s.requirePermission(permissionVerificationRevoke, http.HandlerFunc(s.handleRevokeVerificationAPI)))))
mux.Handle("POST /api/actions/revoke-verification", s.scopedRouteAll(
[]string{permissionVerificationReview, permissionVerificationRevoke},
http.HandlerFunc(s.handleRevokeVerificationAPI)))
// Third-party bot verification. A separate section from the official
// verification block above -- separate tables, separate rights, separate routes.
// Reads and queue decisions need botverification.review; appointing verifiers,
@ -206,7 +216,11 @@ func actorFromContext(ctx context.Context) string {
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
return actor
}
return "admin"
// requireAuthAPI always puts the actor in the context, so this is
// unreachable in practice. It returns a name that is obviously not a real
// operator rather than a plausible one: an audit line reading "admin" would
// silently attribute the action to somebody.
return "unknown"
}
func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
@ -223,7 +237,12 @@ func (s *server) handleApp(w http.ResponseWriter, r *http.Request) {
}
type loginRequest struct {
Secret string `json:"secret"`
// Username selects a named account in admin_console_users. Left empty, the
// credential is checked against TELESRV_ADMIN_UI_PASSWORD / _TOKEN instead,
// which keeps the pre-accounts login working and doubles as the way back in
// if the database is unreachable or every named account is locked out.
Username string `json:"username"`
Secret string `json:"secret"`
}
// sessionTTL bounds a signed panel session and the CSRF cookie that goes with it,
@ -243,7 +262,11 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
if !s.validSecret(req.Secret) {
identity, ok := s.authenticateLogin(r.Context(), req)
if !ok {
// One message and one status for every failure mode -- unknown account,
// wrong password, disabled account. Saying which would let anyone with
// the login form enumerate operators.
writeAPIError(w, http.StatusUnauthorized, "invalid credential")
return
}
@ -252,9 +275,11 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
permissions := newPanelPermissions(s.cfg.Permissions)
permissions := newPanelPermissions(identity.permissions)
value, err := signSession(s.cfg.SessionKey, sessionClaims{
Actor: "admin",
Actor: identity.actor,
UserID: identity.userID,
Epoch: identity.epoch,
Exp: time.Now().Add(sessionTTL).Unix(),
Nonce: newCommandID("sess"),
Permissions: permissions.List(),
@ -274,7 +299,7 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
})
setCSRFCookie(w, csrfToken, sessionTTL)
writeJSON(w, http.StatusOK, map[string]any{
"actor": "admin",
"actor": identity.actor,
"permissions": permissions.List(),
"csrf_token": csrfToken,
"hide_third_party_verification": s.cfg.HideThirdPartyVerification,

View file

@ -26,10 +26,28 @@ type sessionClaims struct {
Actor string `json:"actor"`
Exp int64 `json:"exp"`
Nonce string `json:"nonce"`
// Permissions is the right set granted to this session, taken from
// TELESRV_ADMIN_UI_PERMISSIONS at login. It travels inside the signed cookie
// rather than being re-read per request, so a session keeps the rights it was
// issued with, and it cannot be edited by the browser: the HMAC covers it.
// UserID identifies the admin_console_users row this session belongs to.
//
// Zero means the break-glass operator: whoever logged in with
// TELESRV_ADMIN_UI_PASSWORD / _TOKEN rather than a named account. That
// login has no database row, so it is deliberately exempt from the
// per-request revocation check below -- it is the way back in when the
// database is unreachable or every named account has been locked out.
UserID int64 `json:"uid,omitempty"`
// Epoch is the account's token_epoch at the moment this session was minted.
//
// Permissions travel inside the signed cookie, which is fast but means a
// 12-hour session would otherwise keep whatever rights it was issued with
// long after they were taken away. Every request re-reads the account's
// current epoch and refuses the session if it has moved, so disabling an
// operator, editing their rights or changing their password logs them out
// on their very next request.
Epoch int32 `json:"epoch,omitempty"`
// Permissions is the right set granted to this session. For a named account
// it is a snapshot of that row's permissions; for the break-glass operator
// it comes from TELESRV_ADMIN_UI_PERMISSIONS. It cannot be edited by the
// browser: the HMAC covers it. It is still re-read per request for named
// accounts (see Epoch) so an edit narrows access immediately.
Permissions []string `json:"permissions,omitempty"`
// CSRF is the double-submit token bound to this session. Binding it into the
// signed claims is what makes the cookie/header pair unforgeable by a sibling

View file

@ -35,7 +35,7 @@ func panelServer(t *testing.T, permissions ...string) *server {
func signIn(t *testing.T, srv *server) ([]*http.Cookie, string) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"username":"owpengram","secret":"letmein"}`))
srv.routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
@ -94,7 +94,7 @@ func TestPanelSessionReportsPermissions(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode session: %v", err)
}
if body.Actor != "admin" || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview {
if body.Actor != breakGlassUsername || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview {
t.Fatalf("session=%+v, want the granted permissions reported to the panel", body)
}
}
@ -245,7 +245,7 @@ func originRequest(origin, host string) *http.Request {
func TestLoginRefusesAForeignOrigin(t *testing.T) {
srv := panelServer(t, permissionAll)
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"username":"owpengram","secret":"letmein"}`))
req.Header.Set("Origin", "https://evil.example")
rec := httptest.NewRecorder()
srv.routes().ServeHTTP(rec, req)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -23,8 +23,8 @@
})();
</script>
<script type="module" crossorigin src="/assets/index-C_av3Wxh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bw0J_zQR.css">
<script type="module" crossorigin src="/assets/index-DwoQ5cLE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8s1nuLP.css">
</head>
<body>
<div id="root"></div>

View file

@ -1,4 +1,5 @@
import type {
AdminConsoleUserList,
AccountDetail,
AccountListResponse,
AccountStatsResponse,
@ -144,16 +145,21 @@ export function errorMessage(error: unknown): string {
export const api = {
session: () => request<AdminSession>("/api/session"),
login: async (secret: string) => {
// The built-in operator is named "owpengram" and is checked against the
// configured TELESRV_ADMIN_UI_PASSWORD / _TOKEN -- the break-glass login
// that still works when the database is unreachable. A blank username is
// rejected: there is no anonymous way in.
login: async (secret: string, username = "") => {
const result = await request<AdminLoginResult>("/api/login", {
method: "POST",
body: JSON.stringify({ secret })
body: JSON.stringify({ username, secret })
});
// Stashed here rather than in the caller so no login path can forget it.
rememberCSRFToken(result.csrf_token);
return result;
},
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
adminUsers: () => request<AdminConsoleUserList>("/api/admin-users"),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
sharedDeviceGroups: (params: URLSearchParams) => request<SharedDeviceGroupListResponse>(`/api/accounts/shared-devices?${params.toString()}`),

View file

@ -10,6 +10,8 @@ import {
Megaphone,
MessageSquareText,
Settings,
UserCog,
UserRound,
Share2,
ShieldAlert,
ShieldCheck,
@ -21,7 +23,17 @@ import {
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { api, errorMessage } from "../api";
import { permissionBotVerificationReview, permissionServerManage, permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { permissionBotVerificationReview, permissionServerManage, permissionAdminsManage,
permissionAccountsRead,
permissionChannelsRead,
permissionBotsRead,
permissionMessagesRead,
permissionModerationReview,
permissionBroadcastsRead,
permissionStorageRead,
permissionContentRead,
permissionUsernamesRead,
permissionVerificationReview, useCan, useThirdPartyVerificationHidden } from "../permissions";
import { type Navigate, type RouteState, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme";
import { AddServerLinkModal } from "./AddServerLinkModal";
@ -84,6 +96,18 @@ export function Shell({
// The verification queue is hidden for a session without verification.review:
// the entry would only lead to a 403 (and the route itself is gated as well).
const canReviewVerification = useCan(permissionVerificationReview);
const canManageAdmins = useCan(permissionAdminsManage);
// Each section entry is hidden without the right to open it: the route is
// gated server-side either way, so showing it would only lead to a 403.
const canReadAccounts = useCan(permissionAccountsRead);
const canReadChannels = useCan(permissionChannelsRead);
const canReadBots = useCan(permissionBotsRead);
const canReadMessages = useCan(permissionMessagesRead);
const canReviewModeration = useCan(permissionModerationReview);
const canReadBroadcasts = useCan(permissionBroadcastsRead);
const canReadStorage = useCan(permissionStorageRead);
const canReadContent = useCan(permissionContentRead);
const canReadUsernames = useCan(permissionUsernamesRead);
// Same reasoning for the third-party queue, which has its own right: the two
// sections are granted independently, so one entry can be visible without the other.
const canReviewBotVerification = useCan(permissionBotVerificationReview);
@ -217,22 +241,42 @@ export function Shell({
<div className="sidebar-label">{"Navigation"}</div>
<nav className="nav-list" aria-label={"Primary navigation"}>
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{"Overview"}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
{canReadAccounts && (
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
)}
{canReadChannels && (
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
)}
{canReadBots && (
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
)}
{canReviewModeration && (
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
)}
{canReadBroadcasts && (
<NavLink icon={<Megaphone size={16} />} href="/broadcasts" route={route} navigate={navigate}>{"Broadcasts"}</NavLink>
)}
{canReviewVerification && (
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
)}
{canReviewBotVerification && !thirdPartyVerificationHidden && (
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
)}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
{canReadUsernames && (
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
)}
{canReadStorage && (
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
)}
{canReadContent && (
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
)}
{canReadContent && (
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
)}
{canReadContent && (
<NavLink icon={<Film size={16} />} href="/gif-catalog" route={route} navigate={navigate}>{"GIFs"}</NavLink>
)}
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button
className="nav-section-toggle"
@ -265,6 +309,9 @@ export function Shell({
</div>
)}
</div>
{canManageAdmins && (
<NavLink icon={<UserCog size={16} />} href="/admin-users" route={route} navigate={navigate}>{"Operators"}</NavLink>
)}
{canManageServer && (
<NavLink icon={<Settings size={16} />} href="/server-settings" route={route} navigate={navigate}>{"Server Settings"}</NavLink>
)}
@ -313,7 +360,7 @@ export function Shell({
</div>
<div className="topbar-actions">
<ThemeSwitch />
<span className="actor-pill">{`Actor: ${actor}`}</span>
<span className="actor-pill"><UserRound size={14} /> {actor}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}>
<LogOut size={16} /> {"Log out"}
</button>

View file

@ -0,0 +1,393 @@
import { KeyRound, Lock, RefreshCw, ShieldCheck, UserPlus, X } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, EmptyRow, LoadingRow, PageFrame, QueryPanel, SectionHead } from "../components/ui";
import { groupPermissions, permissionHint, permissionTitle } from "../permissions";
import type { AdminConsoleUser, AdminConsoleSystemOperator } from "../types";
// The operator-accounts screen. The table only reports; every change happens in
// a modal and goes through the panel's usual reason + dry-run + confirm flow,
// because handing somebody the run of the console deserves the same "here is
// what this will do" step as freezing an account.
//
// Everything here is additionally enforced server-side by admins.manage --
// hiding the section is a convenience, not the boundary.
export function AdminUsersPage() {
const [rows, setRows] = useState<AdminConsoleUser[]>([]);
const [system, setSystem] = useState<AdminConsoleSystemOperator | null>(null);
const [available, setAvailable] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState("");
const [editing, setEditing] = useState<AdminConsoleUser | null>(null);
const [resetting, setResetting] = useState<AdminConsoleUser | null>(null);
const [creating, setCreating] = useState(false);
async function load() {
setBusy(true);
setError("");
try {
const result = await api.adminUsers();
setRows(result.rows ?? []);
setSystem(result.system ?? null);
setAvailable(result.available_permissions ?? []);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
setLoaded(true);
}
}
useEffect(() => {
void load();
}, []);
return (
<PageFrame eyebrow={"ACCESS / OPERATORS"} title={"Admin operators"}>
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="toolbar">
<button className="btn primary icon-text" type="button" onClick={() => setCreating(true)}>
<UserPlus size={15} /> {"New operator"}
</button>
<button className="btn icon-text" type="button" onClick={() => void load()} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</div>
</QueryPanel>
<SectionHead title={"Operators"} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Username"}</th>
<th>{"Can do"}</th>
<th>{"Status"}</th>
<th>{"Last login"}</th>
<th></th>
</tr>
</thead>
<tbody>
{/* The built-in operator first: it has the most rights and no
database row, so a list that started with the named accounts
would put the most powerful login last, or nowhere. */}
{system && (
<tr>
<td className="mono">
{system.username} <span className="pill">{"built-in"}</span>
</td>
<td><PermissionChips permissions={system.permissions} /></td>
<td><span className="pill good">{"Enabled"}</span></td>
<td className="mono">{"—"}</td>
<td>
<span className="muted icon-text">
<Lock size={13} /> {"Set in the server environment"}
</span>
</td>
</tr>
)}
{rows.map((row) => (
<tr key={row.id}>
<td className="mono">{row.username}</td>
<td><PermissionChips permissions={row.permissions} /></td>
<td>
{row.enabled
? <span className="pill good">{"Enabled"}</span>
: <span className="pill">{"Disabled"}</span>}
</td>
<td className="mono">{row.last_login_at ? new Date(row.last_login_at).toLocaleString() : "—"}</td>
<td>
<button className="btn icon-text" type="button" onClick={() => setEditing(row)}>
<ShieldCheck size={14} /> {"Access"}
</button>
<button className="btn icon-text" type="button" onClick={() => setResetting(row)}>
<KeyRound size={14} /> {"Password"}
</button>
</td>
</tr>
))}
{rows.length === 0 && !system &&
(busy || !loaded ? <LoadingRow colSpan={5} /> : <EmptyRow colSpan={5} />)}
</tbody>
</table>
</div>
{creating && (
<OperatorModal
title={"New operator"}
available={available}
onClose={() => setCreating(false)}
onDone={() => { setCreating(false); void load(); }}
/>
)}
{editing && (
<OperatorModal
title={`Access for ${editing.username}`}
available={available}
existing={editing}
onClose={() => setEditing(null)}
onDone={() => { setEditing(null); void load(); }}
/>
)}
{resetting && (
<PasswordModal
operator={resetting}
onClose={() => setResetting(null)}
onDone={() => { setResetting(null); void load(); }}
/>
)}
</PageFrame>
);
}
function PermissionChips({ permissions }: { permissions: string[] }) {
if (permissions.length === 0) {
return <span className="muted">{"nothing yet"}</span>;
}
return (
<span className="chip-row">
{permissions.map((p) => (
<span className="chip" key={p} title={p}>{permissionTitle(p)}</span>
))}
</span>
);
}
// PermissionPicker lists the rights by what they let someone do, split into the
// section of the console each governs -- twenty-six checkboxes in one run is a
// wall nobody reads, and the grouping is what makes "what can this person
// actually touch" answerable at a glance.
//
// The raw permission string stays as each row's tooltip, so the screen never
// hides what is actually being stored.
function PermissionPicker({
available,
selected,
onToggle,
onToggleGroup
}: {
available: string[];
selected: string[];
onToggle: (permission: string, on: boolean) => void;
onToggleGroup: (permissions: string[], on: boolean) => void;
}) {
return (
<div className="permission-groups">
{groupPermissions(available).map((group) => {
const all = group.permissions.every((p) => selected.includes(p));
return (
<section className="permission-group" key={group.title}>
<div className="permission-group-head">
<div>
<strong>{group.title}</strong>
<small>{group.hint}</small>
</div>
<button
className="btn compact"
type="button"
onClick={() => onToggleGroup(group.permissions, !all)}
>
{all ? "Clear" : "Select all"}
</button>
</div>
<div className="permission-grid">
{group.permissions.map((permission) => (
<label className="permission-item" key={permission} title={permission}>
<input
type="checkbox"
checked={selected.includes(permission)}
onChange={(event) => onToggle(permission, event.target.checked)}
/>
<span className="permission-copy">
<strong>{permissionTitle(permission)}</strong>
<small>{permissionHint(permission)}</small>
</span>
</label>
))}
</div>
</section>
);
})}
</div>
);
}
// OperatorModal creates a new operator, or edits an existing one's access. The
// same shape either way: the only difference is whether a username and password
// are being chosen.
//
// Laid out as head / scrolling body / action bar like every other command modal
// in the panel, so a long permission list scrolls inside the dialog instead of
// pushing its own confirm button off the screen.
function OperatorModal({
title,
available,
existing,
onClose,
onDone
}: {
title: string;
available: string[];
existing?: AdminConsoleUser;
onClose: () => void;
onDone: () => void;
}) {
const [username, setUsername] = useState(existing?.username ?? "");
const [password, setPassword] = useState("");
const [permissions, setPermissions] = useState<string[]>(existing?.permissions ?? []);
const [enabled, setEnabled] = useState(existing?.enabled ?? true);
const isEdit = Boolean(existing);
// Only the shape the server insists on: a username it will accept, and a
// password that is actually present. Length is the operator's business.
const incomplete = isEdit
? false
: username.trim().length < 3 || password.trim() === "";
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={title}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Operators"}</div>
<h2>{title}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
{!isEdit && (
<div className="operator-identity">
<label className="duration-field">
<span>{"Username"}</span>
<input
autoFocus
value={username}
spellCheck={false}
autoCapitalize="none"
placeholder={"letters, digits, dot, dash or underscore"}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="duration-field">
<span>{"Password"}</span>
<input
type="password"
value={password}
autoComplete="new-password"
onChange={(event) => setPassword(event.target.value)}
/>
</label>
</div>
)}
<PermissionPicker
available={available}
selected={permissions}
onToggle={(permission, on) =>
setPermissions((current) =>
on ? [...current, permission] : current.filter((p) => p !== permission)
)
}
onToggleGroup={(group, on) =>
setPermissions((current) =>
on
? [...current, ...group.filter((p) => !current.includes(p))]
: current.filter((p) => !group.includes(p))
)
}
/>
<label className="permission-item standalone">
<input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} />
<span className="permission-copy">
<strong>{"Account is enabled"}</strong>
<small>{"A disabled operator cannot sign in"}</small>
</span>
</label>
{isEdit && (
<Alert>{"The new access applies from this operator's next request. They stay signed in."}</Alert>
)}
</div>
<div className="modal-actions toolbar">
<button className="btn" type="button" onClick={onClose}>{"Cancel"}</button>
<ActionButton
label={isEdit ? "Save access" : "Create operator"}
path={isEdit ? "/api/actions/set-admin-operator-access" : "/api/actions/create-admin-operator"}
tone="primary"
disabled={incomplete}
icon={isEdit ? <ShieldCheck size={15} /> : <UserPlus size={15} />}
payload={() =>
isEdit
? { id: existing?.id, permissions, enabled }
: { username: username.trim(), password, permissions, enabled }
}
onDone={onDone}
/>
</div>
</section>
</div>,
document.body
);
}
function PasswordModal({
operator,
onClose,
onDone
}: {
operator: AdminConsoleUser;
onClose: () => void;
onDone: () => void;
}) {
const [password, setPassword] = useState("");
return createPortal(
<div className="modal-backdrop" role="presentation">
<section className="modal command-modal narrow" role="dialog" aria-modal="true" aria-label={"Set password"}>
<div className="modal-head">
<div>
<div className="eyebrow">{"Operators"}</div>
<h2>{`Password for ${operator.username}`}</h2>
</div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div>
<div className="command-body">
<label className="duration-field">
<span>{"New password"}</span>
<input
autoFocus
type="password"
value={password}
autoComplete="new-password"
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<Alert>{"Changing the password signs this operator out of any session they already have."}</Alert>
</div>
<div className="modal-actions toolbar">
<button className="btn" type="button" onClick={onClose}>{"Cancel"}</button>
<ActionButton
label={"Set password"}
path={"/api/actions/set-admin-operator-password"}
tone="primary"
disabled={password.trim() === ""}
icon={<KeyRound size={15} />}
payload={() => ({ id: operator.id, password })}
onDone={onDone}
/>
</div>
</section>
</div>,
document.body
);
}

View file

@ -6,6 +6,10 @@ import { ThemeSwitch } from "../theme";
import type { AdminSession } from "../types";
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
// Deliberately not pre-filled: the built-in operator is a default name, not a
// default identity, and typing it is the difference between choosing to use
// it and drifting into it. The server rejects a blank username either way.
const [username, setUsername] = useState("");
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@ -17,7 +21,7 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
try {
// The login answer carries the permission set and the CSRF token; api.login
// remembers the token, the session state keeps the rights.
const result = await api.login(secret);
const result = await api.login(secret, username);
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
} catch (err) {
setError(errorMessage(err));
@ -44,7 +48,6 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
</div>
<div className="login-head-actions">
<ThemeSwitch />
<span className="login-chip">{"Local access"}</span>
</div>
</div>
<div className="login-copy">
@ -54,9 +57,23 @@ export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => voi
{error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}>
<label>
<span>{"Admin password or token"}</span>
<span>{"Username"}</span>
<input
autoFocus
type="text"
value={username}
autoComplete="username"
spellCheck={false}
autoCapitalize="none"
// A hint, not a value: it names the built-in operator without
// filling the field in, so signing in as it stays a deliberate act.
placeholder={"owpengram"}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label>
<span>{"Password"}</span>
<input
type="password"
value={secret}
autoComplete="current-password"

View file

@ -16,6 +16,7 @@ import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
import { StickerSetsPage } from "./StickerSetsPage";
import { GifCatalogPage } from "./GifCatalogPage";
import { AdminUsersPage } from "./AdminUsersPage";
import { ServerSettingsPage } from "./ServerSettingsPage";
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
import { ModerationCasesPage } from "./ModerationCasesPage";
@ -28,7 +29,7 @@ import {
PermissionGate,
ThirdPartyVerificationHiddenGate,
permissionBotVerificationReview,
permissionServerManage,
permissionServerManage, permissionAdminsManage,
permissionVerificationReview
} from "../permissions";
@ -126,6 +127,13 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/gif-catalog") {
return <GifCatalogPage />;
}
if (route.path === "/admin-users") {
return (
<PermissionGate permission={permissionAdminsManage}>
<AdminUsersPage />
</PermissionGate>
);
}
if (route.path === "/server-settings") {
return (
<PermissionGate permission={permissionServerManage}>

View file

@ -17,6 +17,22 @@ export const permissionBotVerificationManage = "botverification.manage";
// Server Settings: identity, .env, restart/update. One right, not
// review/manage -- see the constant's doc comment in security.go.
export const permissionServerManage = "server.manage";
// Operator accounts. The one right that can hand out every other right, so it
// is never implied by anything else -- see the constant's doc comment in
// security.go.
export const permissionAdminsManage = "admins.manage";
// Section rights, in read/manage pairs following the sidebar -- see the const
// block in security.go, which these must match exactly.
export const permissionAccountsRead = "accounts.read";
export const permissionChannelsRead = "channels.read";
export const permissionBotsRead = "bots.read";
export const permissionMessagesRead = "messages.read";
export const permissionModerationReview = "moderation.review";
export const permissionBroadcastsRead = "broadcasts.read";
export const permissionStorageRead = "storage.read";
export const permissionContentRead = "content.read";
export const permissionUsernamesRead = "usernames.read";
export const permissionDashboardRead = "dashboard.read";
// GET /api/session is read once at boot; the panel keeps the answer here so a
// section the session may not use is hidden instead of rendered into a 403. This
@ -117,3 +133,117 @@ export function ThirdPartyVerificationHiddenGate({ children }: { children: React
</PageFrame>
);
}
// Human-readable names for the permission strings. The raw value is what the
// backend stores and checks, but "content.manage" is a machine's word for it --
// an operator ticking boxes should read what the right actually lets someone do.
//
// Anything missing from this map falls back to the raw string rather than being
// hidden, so a right added on the server still appears (just untranslated)
// instead of silently vanishing from the editor.
const permissionLabels: Record<string, { title: string; hint: string }> = {
"accounts.read": { title: "View accounts", hint: "Browse users, their profiles and sessions" },
"accounts.manage": { title: "Edit accounts", hint: "Change profiles, usernames, freeze and revoke sessions" },
"channels.read": { title: "View groups and channels", hint: "Browse supergroups and channels" },
"channels.manage": { title: "Edit groups and channels", hint: "Change settings, usernames and avatars" },
"bots.read": { title: "View bots", hint: "Browse the bot list and their details" },
"bots.manage": { title: "Create and delete bots", hint: "Add new bots and remove existing ones" },
"bots.token.read": { title: "Reveal bot tokens", hint: "Export a bot's live credential" },
"messages.read": { title: "View messages", hint: "Read private and group message history" },
"messages.manage": { title: "Delete messages", hint: "Remove messages and clear history" },
"moderation.review": { title: "Handle reports", hint: "Work the moderation queue and decide cases" },
"broadcasts.read": { title: "View broadcasts", hint: "See past and scheduled broadcasts" },
"broadcasts.send": { title: "Send broadcasts", hint: "Deliver a message to many users at once" },
"content.read": { title: "View stickers, emoji and GIFs", hint: "Browse the packs and the GIF catalogue" },
"content.manage": { title: "Edit stickers, emoji and GIFs", hint: "Create, rename and remove packs and catalogue entries" },
"usernames.read": { title: "View NFT usernames", hint: "Browse collectible usernames" },
"usernames.manage": { title: "Manage NFT usernames", hint: "Mint, transfer and revoke collectible usernames" },
"storage.read": { title: "View storage", hint: "See media usage per account" },
"storage.manage": { title: "Purge storage", hint: "Manually delete stored media" },
"dashboard.read": { title: "View the dashboard", hint: "See the overview counters and server health" },
"premium.manage": { title: "Manage Premium", hint: "Grant, revoke and refund Premium" },
"verification.review": { title: "Verify accounts", hint: "Work the verification queue and grant badges" },
"verification.revoke": { title: "Remove verification", hint: "Take a granted badge away (needs the right above too)" },
"botverification.review": { title: "Handle third-party marks", hint: "Work the third-party verification queue" },
"botverification.manage": { title: "Appoint verifiers", hint: "Grant verifier status and curate mark icons" },
"server.manage": { title: "Server settings", hint: "Identity, .env editing, restart and update" },
"admins.manage": { title: "Manage operators", hint: "Create operators and decide what everyone can do" },
"*": { title: "Full access", hint: "Every right, including future ones" }
};
export function permissionTitle(permission: string): string {
return permissionLabels[permission]?.title ?? permission;
}
export function permissionHint(permission: string): string {
return permissionLabels[permission]?.hint ?? "";
}
// Rights grouped by the part of the console they govern, so the editor reads as
// a few short decisions instead of one wall of twenty-six checkboxes.
//
// The order is roughly "everyday work first, keys to the building last": an
// operator scanning down the list meets the routine rights before the ones that
// can undo the deployment.
export const permissionGroups: { title: string; hint: string; permissions: string[] }[] = [
{
title: "People and chats",
hint: "Users, groups and their message history",
permissions: ["accounts.read", "accounts.manage", "channels.read", "channels.manage", "messages.read", "messages.manage"]
},
{
title: "Moderation and verification",
hint: "Reports, badges and third-party marks",
permissions: ["moderation.review", "verification.review", "verification.revoke", "botverification.review", "botverification.manage"]
},
{
title: "Content",
hint: "Sticker packs, emoji, GIFs and collectible usernames",
permissions: ["content.read", "content.manage", "usernames.read", "usernames.manage"]
},
{
title: "Bots",
hint: "The bot roster and its credentials",
permissions: ["bots.read", "bots.manage", "bots.token.read"]
},
{
title: "Broadcasting",
hint: "Messages sent to many users at once",
permissions: ["broadcasts.read", "broadcasts.send"]
},
{
title: "Storage and overview",
hint: "Media usage and the dashboard",
permissions: ["storage.read", "storage.manage", "dashboard.read"]
},
{
title: "Billing",
hint: "Premium grants and refunds",
permissions: ["premium.manage"]
},
{
title: "The console itself",
hint: "The two rights that can change the deployment or hand out every other right",
permissions: ["server.manage", "admins.manage"]
}
];
// groupPermissions arranges the server's list into the groups above. Anything
// the server offers that no group claims is collected at the end rather than
// dropped, so a right added on the backend still appears here without this file
// having to be edited first.
export function groupPermissions(available: string[]): { title: string; hint: string; permissions: string[] }[] {
const remaining = new Set(available);
const out: { title: string; hint: string; permissions: string[] }[] = [];
for (const group of permissionGroups) {
const present = group.permissions.filter((p) => remaining.has(p));
present.forEach((p) => remaining.delete(p));
if (present.length > 0) {
out.push({ title: group.title, hint: group.hint, permissions: present });
}
}
if (remaining.size > 0) {
out.push({ title: "Other", hint: "Rights this console version does not have a group for", permissions: [...remaining] });
}
return out;
}

View file

@ -482,8 +482,11 @@ a {
.actor-pill {
display: inline-flex;
min-height: 30px;
/* Icon and name read as one label rather than two adjacent things. */
gap: 6px;
align-items: center;
padding: 0 10px;
padding: 0 12px;
font-weight: 600;
color: var(--text-soft);
background: var(--panel-subtle);
border: 1px solid var(--line);

View file

@ -936,3 +936,169 @@ textarea:focus {
}
}
/* Operator accounts (AdminUsersPage). The permission picker is a checkbox grid
rather than a role dropdown: the backend stores a permission set, so the
screen shows exactly that set instead of a friendlier abstraction that could
drift from what the routes enforce.
Selectors carry .form-stack because these labels live inside one, and
".form-stack label { display: grid }" would otherwise out-specify a bare
.permission-item and stack the box above its own text. */
.permission-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 6px;
margin: 2px 0 8px;
}
.form-stack .permission-item,
.permission-item {
display: flex;
align-items: center;
gap: 8px;
width: auto;
padding: 7px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--panel-subtle);
cursor: pointer;
font-size: 12px;
transition: border-color 140ms ease;
}
.permission-item:hover {
border-color: var(--brand-tint-border);
}
/* Explicit box size: the generic "input" rule gives fields a text-input's
padding and .form-stack stretches them to 100%, neither of which suits a
checkbox. */
.form-stack .permission-item input[type="checkbox"],
.permission-item input[type="checkbox"] {
width: 15px;
height: 15px;
flex: 0 0 auto;
margin: 0;
padding: 0;
border-radius: 4px;
accent-color: var(--brand);
cursor: pointer;
}
/* Title over hint. The checkbox stays vertically centred against the pair
rather than against the first line, so a two-line entry does not look
top-heavy. */
.permission-copy {
display: grid;
min-width: 0;
gap: 1px;
}
.permission-copy strong {
overflow: hidden;
color: var(--text);
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.permission-copy small {
overflow: hidden;
color: var(--muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The standalone Enabled toggle is one control, not a grid cell, so it sits at
its natural width instead of stretching across the row. */
.permission-item.standalone {
justify-self: start;
width: max-content;
}
/* A granted permission, listed in the table. It carries the human name now, so
it is set in the UI font -- the raw "content.manage" string stays available
as the chip's tooltip for anyone who needs to match it against the .env. */
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.chip {
display: inline-block;
padding: 2px 7px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-strong);
color: var(--text-soft);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.pill {
display: inline-block;
padding: 2px 9px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-strong);
color: var(--text-soft);
font-size: 11px;
font-weight: 700;
}
.pill.good {
border-color: var(--good-border);
color: var(--good);
}
/* Grouped permission editor. Each group is a labelled block so the twenty-odd
rights read as a few short decisions rather than one undifferentiated run. */
.permission-groups {
display: grid;
gap: 14px;
}
.permission-group {
display: grid;
gap: 8px;
}
.permission-group-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.permission-group-head strong {
display: block;
color: var(--heading);
font-size: 13px;
}
.permission-group-head small {
display: block;
margin-top: 1px;
color: var(--muted);
font-size: 11px;
}
/* Username and password sit side by side above the rights, so the identity
fields do not read as the first permission group. */
.operator-identity {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
/* The password dialog has one field; the command modal's default width would
leave it stranded in the middle of a mostly empty sheet. */
.modal.narrow {
width: min(460px, 100%);
}

View file

@ -907,3 +907,33 @@ export type DockerService = {
state: string;
health: string;
};
// One admin console operator. Mirrors AdminConsoleUser in adminusers.go; the
// password hash deliberately has no representation here.
export type AdminConsoleUser = {
id: number;
username: string;
permissions: string[];
enabled: boolean;
token_epoch: number;
created_at: string;
updated_at: string;
last_login_at?: string | null;
};
// The built-in operator backed by TELESRV_ADMIN_UI_PASSWORD / _TOKEN. It has no
// database row, so it carries no id and cannot be edited from the panel.
export type AdminConsoleSystemOperator = {
username: string;
permissions: string[];
enabled: boolean;
system: true;
};
export type AdminConsoleUserList = {
system?: AdminConsoleSystemOperator;
rows: AdminConsoleUser[];
// The rights the server is willing to assign, so the editor cannot drift
// from what the routes actually enforce.
available_permissions: string[];
};