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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue