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