server admin panel is now supports multiple operators profiles

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

View file

@ -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