Implement RBAC v52, org scoping and assorted fixes

Adds a full Phase-52 RBAC implementation and multiple server/frontend fixes. Key changes: new auth/permissions.go with 28 granular permissions and DefaultRolePermissions, expanded 7-role hierarchy and helpers in auth/roles.go, JWT org context and GenerateOrgToken, requirePermission/requireOrgMembership middlewares (Go + Node.js), DB schema & adapter changes for role_permissions and is_server_admin, org role boundary checks and peer org scoping, and guards for last-admin demotion and self-demotion. Also: TCP EOF/connection-reset log filtering in signal/relay servers, improved startup banner port display, KEYS_PATH auto-detect warning, CSS hover/transition layout fixes, admin password race mitigation, ID-change ghost peer cleanup, added Tauri ACL schema files, and a new RBAC_PHASE52.md doc. Misc: numerous web-nodejs i18n, CSS, JS and route updates and an updated .github/copilot-instructions.md timestamp/summary.
This commit is contained in:
UNITRONIX
2026-04-10 23:40:55 +02:00
parent 7a0b3b7387
commit 45e5fda9d0
41 changed files with 7305 additions and 224 deletions
+39 -2
View File
@@ -5,7 +5,7 @@
---
## 📊 Stan Projektu (aktualizacja: 2026-03-25)
## 📊 Stan Projektu (aktualizacja: 2026-04-10)
### Wersja Skryptów ALL-IN-ONE (v2.4.0)
@@ -846,6 +846,43 @@ sudo apt-get install -y build-essential libsqlite3-dev pkg-config libssl-dev git
364. [ ] **Cross-platform build & installers**: Windows (MSI + NSSM service), Linux (deb/rpm + systemd), macOS (pkg + launchd). Per-platform: screen capture, input model, permissions/UAC, secret storage, firewall, autostart, script execution differences.
365. [ ] **Testing**: Unit tests, integration tests, registration tests, security tests, automation tests, cross-platform compatibility tests, update tests, connection loss resilience tests, server/certificate reconfiguration tests. Spec: `docs/new_agents/client2.md`.
#### GitHub Issue Triage & Fixes (Phase 51) ✅ COMPLETED 2026-04-10
366. [x] **Closed 16 GitHub issues**: Full audit of all 22 open issues — 14 already fixed in codebase (verified + closed with detailed comments), 2 fixed with new code
367. [x] **TCP accept EOF log spam (#100)**: Added `errors.Is(err, io.EOF)` + `strings.Contains("connection reset"|"use of closed")` filter in both `signal/server.go` and `relay/server.go` `serveTCP()` loops. Silences benign scanner/probe noise.
368. [x] **Startup banner port confusion (#98)**: `server.js` `printStartupBanner()` now shows all active ports, protocol labels (HTTP/HTTPS), redirect info, and Go API URL.
369. [x] **KEYS_PATH auto-detect warning (#89)**: `config.js` warns when KEYS_PATH was auto-detected but no `.api_key` or `id_ed25519` found at resolved path.
370. [x] **CSS hover layout shift (#75)**: Added `transform: translateY(0)` base state to 7 elements across 5 CSS files. Fixed `transition: all` → specific properties on `.widget-action-btn`.
371. [x] **Admin password race condition (#88)**: `ensureDefaultAdmin()` in `authService.js` now retries reading `.admin_credentials` after 3-second delay on fresh install (Go server may not have written file yet). Falls back to writing generated password to `data/.admin_credentials` for discoverability.
372. [x] **ID change ghost entries (#97)**: Two fixes: (1) `syncGoPeersSqlite()` now cross-references `id_change_history` to DELETE ghost peer entries with renamed IDs. (2) `/api/bd/register` checks `getRenamedPeerId()` and returns 409 with `new_id` for stale IDs.
373. [x] **Posted analysis comments**: Remaining open issues (#93 EJS template, #94 MGMT token, #78 Docker SQLITE_READONLY, #76 tag sync, #74 access controls) received detailed analysis comments with actionable steps.
374. [x] **Discussion #99 RBAC response**: Comprehensive RBAC analysis comparing current 4-tier vs proposed 6-tier hierarchy, 5 critical gaps identified, 3-phase implementation roadmap. Saved to `docs/_internal/DISCUSSION_99_RBAC_RESPONSE.md`.
#### RBAC — Granular Permissions & Data Scoping (Phase 52) ✅ COMPLETED 2026-04-10
375. [x] **`auth/permissions.go` created**: 28 granular permission constants (device.view/.connect/.edit/.delete/.ban/.change_id, user.view/.create/.edit/.delete, server.config/.keys, org.create/.edit/.delete/.manage_users/.manage_devices, audit.view, metrics.view, blocklist.edit, cdap.view/.command/.terminal/.files, enrollment.manage/.approve, chat.access, branding.edit). `DefaultRolePermissions` map (admin=all 28, operator=12, viewer=5, pro=1). `RoleHasPermission()`, `ValidPermission()` helpers.
376. [x] **JWT org context**: Added `OrgID string` to `auth.Claims`, `GenerateOrgToken()` method. Org login now embeds `org_id` in JWT. `authMiddleware` extracts and injects `org_id` into request context.
377. [x] **`requirePermission()` middleware (Go)**: Checks DB `role_permissions` table for custom overrides first, falls back to `DefaultRolePermissions`. Admin always passes. ~30 routes migrated from `requireRole` to `requirePermission`.
378. [x] **`requireOrgMembership()` middleware**: Enforces org membership for org-scoped endpoints. Global admins bypass. JWT `org_id` matching + DB lookup fallback.
379. [x] **Data scoping**: `handleListPeers` uses `ListPeersForOrg(orgID)` when JWT has org_id. `handleListOrgs` filtered by membership for non-admins.
380. [x] **`role_permissions` table**: SQLite + PostgreSQL schema migration. `ListRolePermissions`, `SetRolePermission`, `DeleteRolePermission`, `HasRolePermission`, `ListPeersForOrg` — implemented in both adapters.
381. [x] **`User.IsServerAdmin` field**: Added to DB model, SQLite + PostgreSQL schema migration (`is_server_admin` column), all user queries updated to scan/persist it. Exposed in `/api/auth/me` and `/api/users` responses.
382. [x] **Super admin protection**: Self-demotion prevention (admin cannot lower own role). Role boundary enforcement (cannot assign role > own). Server admin protection (only server admins can modify/delete other server admins). Last-admin deletion guard (pre-existing).
383. [x] **Node.js `requirePermission()` middleware**: Added to `web-nodejs/middleware/auth.js` with matching `DEFAULT_ROLE_PERMISSIONS` map mirroring Go defaults. Exported `requirePermission()` and `roleHasPermission()` functions.
384. [x] **Documentation**: `docs/features/RBAC_PHASE52.md` with full permission table, default role maps, override examples, data scoping details, DB schema.
#### RBAC — 6-Role Hierarchy & Org Boundary Enforcement (Phase 52b) ✅ COMPLETED 2026-04-10
385. [x] **3 new global roles**: Added `super_admin`, `server_admin`, `global_admin` to `auth/roles.go`. `RoleSuperAdmin`, `RoleServerAdmin`, `RoleGlobalAdmin` constants. Legacy `admin` = alias for `super_admin`. `ValidRole()` accepts 7 roles.
386. [x] **Branched role hierarchy**: `RoleLevel()` returns 5 for super_admin/admin, 4 for server_admin/global_admin (parallel), 2 for operator, 1 for viewer, 0 for pro. `IsSuperAdminRole()`, `IsServerLevel()` helpers.
387. [x] **`CanAssignRole()` function**: Enforces branched role assignment boundaries. Super admin → any, global_admin → operator/viewer/pro only, server_admin → none, operator/viewer/pro → none.
388. [x] **7-role permission maps**: `DefaultRolePermissions` expanded to 7 entries. `server_admin`: 8 perms (server.config, server.keys, blocklist.edit, user.view, device.view, audit.view, metrics.view, enrollment.manage). `global_admin`: 22 perms (user/org/device/cdap/audit — NO server.config/server.keys). `RoleHasPermission()` updated to use `IsSuperAdminRole()`.
389. [x] **Org role boundary enforcement**: `OrgRoleLevel()` (owner=40, admin=30, operator=20, user=10), `OrgCanAssignRole()` (owner → admin/op/user, admin → op/user, others → none), `ValidOrgRole()` — all in `db/database.go`.
390. [x] **Org privilege escalation fixed**: `handleCreateOrgUser` — caller's org-role checked via `GetOrgUserByUsername` + `OrgCanAssignRole`. Super/global admins bypass. `handleUpdateOrgUser` — self-modification blocked, caller authority check, cannot modify user at or above own org level.
391. [x] **Org user visibility scoping**: `handleListOrgUsers` — org users with role "user" only see themselves. Org admin/operator/owner see all.
392. [x] **Last-admin demotion guard**: `handleUpdateUser` — when demoting a super_admin/admin, counts remaining admins. If sole admin, returns 409 Conflict.
393. [x] **Peer org scope check**: `peerOrgScopeCheck()` helper verifies org-scoped users can only access devices assigned to their org. Applied to 7 critical endpoints: GET/DELETE/PATCH peer, ban/unban, change-id, metrics.
394. [x] **requirePermission super_admin bypass**: Updated Go `requirePermission()` from `userRole == RoleAdmin` to `IsSuperAdminRole(userRole)`.
395. [x] **requireOrgMembership global_admin bypass**: Updated to allow super_admin AND global_admin to access any org.
396. [x] **Node.js 7-role middleware**: Updated `DEFAULT_ROLE_PERMISSIONS` with 7 entries (super_admin, admin, server_admin, global_admin, operator, viewer, pro). Added `SUPER_ADMIN_ROLES` set + `isSuperAdminRole()`. Updated `requireRole`, `requireAdmin`, `requirePermission` to handle new roles.
### Konfiguracja przez Zmienne Środowiskowe
```bash
@@ -1115,4 +1152,4 @@ All code changes MUST include a security review as part of the implementation pr
---
*Ostatnia aktualizacja: 2026-03-27 (Roadmap: Draggable zone borders #281, Unattended access management #300, Desktop binary size reduction #305 (42.5%), WOL audit fix. Previous: Phases 47-48 + Windows 11 snap layouts, desktop login fix, Chat E2E, Web Remote, UI polish, widget groups) przez GitHub Copilot*
*Ostatnia aktualizacja: 2026-04-10 (Phase 51: GitHub Issue Triage — 16 issues closed, TCP EOF filter, admin password race fix, ID change ghost cleanup, CSS animations, RBAC discussion response. Previous: Phases 47-48 + Windows 11 snap layouts, desktop login fix, Chat E2E, Web Remote, UI polish, widget groups) przez GitHub Copilot*
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+207 -14
View File
@@ -24,7 +24,8 @@ type contextKey string
const (
ctxKeyRole contextKey = "role"
ctxKeyUsername contextKey = "username"
ctxKeyUser contextKey = "user" // Full db.User object
ctxKeyUser contextKey = "user" // Full db.User object
ctxKeyOrgID contextKey = "org_id" // Organization ID (empty for global users)
)
// getRoleFromCtx returns the authenticated user's role from the request context.
@@ -43,6 +44,37 @@ func getUsernameFromCtx(r *http.Request) string {
return ""
}
// getOrgIDFromCtx returns the org ID embedded in the JWT token (empty for global users).
func getOrgIDFromCtx(r *http.Request) string {
if v, ok := r.Context().Value(ctxKeyOrgID).(string); ok {
return v
}
return ""
}
// peerOrgScopeCheck verifies org-scoped users have access to the target peer.
// Returns true if access is allowed, false if denied (response already written).
// Super admins, global admins, and users without org context bypass the check.
func (s *Server) peerOrgScopeCheck(w http.ResponseWriter, r *http.Request, peerID string) bool {
userRole := getRoleFromCtx(r)
// Super admin and global admin can access any peer.
if auth.IsSuperAdminRole(userRole) || userRole == auth.RoleGlobalAdmin {
return true
}
orgID := getOrgIDFromCtx(r)
if orgID == "" {
// Global user (no org scope) — allowed (permissions already checked by requirePermission).
return true
}
// Org-scoped user: peer must be assigned to their org.
od, _ := s.db.GetOrgDevice(orgID, peerID)
if od != nil {
return true
}
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Device not in your organization"})
return false
}
// requireRole wraps a handler to enforce minimum role permissions.
func (s *Server) requireRole(role string, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -55,6 +87,84 @@ func (s *Server) requireRole(role string, handler http.HandlerFunc) http.Handler
}
}
// requirePermission wraps a handler to enforce a specific granular permission.
// Checks custom DB overrides first, then falls back to default role permissions.
func (s *Server) requirePermission(perm string, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userRole := getRoleFromCtx(r)
// Super admin (and legacy admin) always has all permissions
if auth.IsSuperAdminRole(userRole) {
handler(w, r)
return
}
// Check DB-stored custom permission overrides first
if s.db != nil {
granted, err := s.db.HasRolePermission(userRole, perm)
if err == nil {
if granted {
handler(w, r)
return
}
// explicit deny in DB — reject even if default says yes
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Insufficient permissions"})
return
}
// err != nil means no override found — fall through to defaults
}
// Fall back to built-in default role permissions
if auth.RoleHasPermission(userRole, perm) {
handler(w, r)
return
}
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Insufficient permissions"})
}
}
// requireOrgMembership wraps a handler to enforce that the authenticated user
// belongs to the organization identified by the URL path parameter.
// Global admins bypass the check. Org-scoped users must match their JWT org_id.
func (s *Server) requireOrgMembership(paramName string, handler http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userRole := getRoleFromCtx(r)
// Super admin, legacy admin, and global_admin can access any org
if auth.IsSuperAdminRole(userRole) || userRole == auth.RoleGlobalAdmin {
handler(w, r)
return
}
// Get the org ID from the URL path parameter
targetOrgID := r.PathValue(paramName)
if targetOrgID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Organization ID required"})
return
}
// Check if the user's JWT org_id matches the target org
userOrgID := getOrgIDFromCtx(r)
if userOrgID != "" && userOrgID == targetOrgID {
handler(w, r)
return
}
// For global users (no org_id in JWT), check DB membership
username := getUsernameFromCtx(r)
if username != "" {
orgUser, err := s.db.GetOrgUserByUsername(targetOrgID, username)
if err == nil && orgUser != nil {
handler(w, r)
return
}
}
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Not a member of this organization"})
}
}
// --- Login Handlers ---
// handleLogin authenticates a user with username+password and returns a JWT token.
@@ -205,12 +315,13 @@ func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"username": user.Username,
"role": user.Role,
"totp_enabled": user.TOTPEnabled,
"created_at": user.CreatedAt,
"last_login": user.LastLogin,
"id": user.ID,
"username": user.Username,
"role": user.Role,
"totp_enabled": user.TOTPEnabled,
"is_server_admin": user.IsServerAdmin,
"created_at": user.CreatedAt,
"last_login": user.LastLogin,
})
}
@@ -226,19 +337,21 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
}
type userView struct {
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login,omitempty"`
ID int64 `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
TOTPEnabled bool `json:"totp_enabled"`
IsServerAdmin bool `json:"is_server_admin"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login,omitempty"`
}
result := make([]userView, len(users))
for i, u := range users {
result[i] = userView{
ID: u.ID, Username: u.Username, Role: u.Role,
TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt, LastLogin: u.LastLogin,
TOTPEnabled: u.TOTPEnabled, IsServerAdmin: u.IsServerAdmin,
CreatedAt: u.CreatedAt, LastLogin: u.LastLogin,
}
}
writeJSON(w, http.StatusOK, result)
@@ -268,6 +381,13 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return
}
// Role boundary: use CanAssignRole for branched hierarchy support.
callerRole := getRoleFromCtx(r)
if !auth.CanAssignRole(callerRole, body.Role) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Cannot assign a role higher than your own"})
return
}
hash, err := auth.HashPassword(body.Password)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Password hash failed"})
@@ -330,6 +450,46 @@ func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid role"})
return
}
callerUsername := getUsernameFromCtx(r)
callerRole := getRoleFromCtx(r)
// Prevent self-demotion (admin cannot lower their own role).
if user.Username == callerUsername && auth.RoleLevel(body.Role) < auth.RoleLevel(user.Role) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Cannot demote yourself"})
return
}
// Role boundary: use CanAssignRole for branched hierarchy.
if !auth.CanAssignRole(callerRole, body.Role) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Cannot assign a role higher than your own"})
return
}
// Prevent demoting the last super-admin/admin.
if auth.IsSuperAdminRole(user.Role) && !auth.IsSuperAdminRole(body.Role) {
users, _ := s.db.ListUsers()
adminCount := 0
for _, u := range users {
if auth.IsSuperAdminRole(u.Role) {
adminCount++
}
}
if adminCount <= 1 {
writeJSON(w, http.StatusConflict, map[string]string{"error": "Cannot demote the last admin"})
return
}
}
// Prevent demoting a server admin unless caller is also a server admin.
if user.IsServerAdmin {
callerUser, _ := s.db.GetUser(callerUsername)
if callerUser == nil || !callerUser.IsServerAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Only server admins can modify other server admins"})
return
}
}
user.Role = body.Role
}
@@ -375,6 +535,16 @@ func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
}
}
// Prevent deleting a server admin unless caller is also a server admin.
if user.IsServerAdmin {
callerUsername := getUsernameFromCtx(r)
callerUser, _ := s.db.GetUser(callerUsername)
if callerUser == nil || !callerUser.IsServerAdmin {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "Only server admins can delete other server admins"})
return
}
}
if err := s.db.DeleteUser(id); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
@@ -647,6 +817,23 @@ func (s *Server) authenticateRequest(r *http.Request) (username, role string, ok
return "", "", false
}
// extractOrgIDFromRequest reads the org_id from the Bearer JWT token.
// Returns empty string if no JWT, no org_id, or API key auth (non-JWT).
func (s *Server) extractOrgIDFromRequest(r *http.Request) string {
if s.jwtManager == nil {
return ""
}
bearer := r.Header.Get("Authorization")
if len(bearer) <= 7 || bearer[:7] != "Bearer " {
return ""
}
claims, err := s.jwtManager.Validate(bearer[7:])
if err != nil {
return ""
}
return claims.OrgID
}
// authMiddleware replaces the old apiKeyMiddleware.
// It authenticates every request and attaches role + username to the context.
// Public endpoints are excluded from authentication.
@@ -687,6 +874,12 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
ctx := context.WithValue(r.Context(), ctxKeyRole, role)
ctx = context.WithValue(ctx, ctxKeyUsername, username)
// Extract org_id from JWT claims (if present)
orgID := s.extractOrgIDFromRequest(r)
if orgID != "" {
ctx = context.WithValue(ctx, ctxKeyOrgID, orgID)
}
// Optionally load full user object for handlers that need it
if user, err := s.db.GetUser(username); err == nil && user != nil {
ctx = context.WithValue(ctx, ctxKeyUser, user)
+91 -10
View File
@@ -34,6 +34,7 @@ import (
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/unitronix/betterdesk-server/auth"
"github.com/unitronix/betterdesk-server/db"
)
@@ -100,6 +101,9 @@ func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
// GET /api/org
func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) {
userRole := getRoleFromCtx(r)
username := getUsernameFromCtx(r)
orgs, err := s.db.ListOrganizations()
if err != nil {
log.Printf("[org] ListOrganizations error: %v", err)
@@ -109,6 +113,22 @@ func (s *Server) handleListOrgs(w http.ResponseWriter, r *http.Request) {
if orgs == nil {
orgs = []*db.Organization{}
}
// Data scoping: non-admin users only see orgs they belong to
if userRole != auth.RoleAdmin {
var filtered []*db.Organization
for _, org := range orgs {
member, err := s.db.GetOrgUserByUsername(org.ID, username)
if err == nil && member != nil {
filtered = append(filtered, org)
}
}
if filtered == nil {
filtered = []*db.Organization{}
}
orgs = filtered
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"organizations": orgs})
}
@@ -253,12 +273,28 @@ func (s *Server) handleCreateOrgUser(w http.ResponseWriter, r *http.Request) {
if body.Role == "" {
body.Role = db.OrgRoleUser
}
if body.Role != db.OrgRoleOwner && body.Role != db.OrgRoleAdmin &&
body.Role != db.OrgRoleOperator && body.Role != db.OrgRoleUser {
if !db.ValidOrgRole(body.Role) {
http.Error(w, `{"error":"invalid role (owner, admin, operator, user)"}`, http.StatusBadRequest)
return
}
// Org role boundary: check caller's authority within this org.
callerRole := getRoleFromCtx(r)
callerUsername := getUsernameFromCtx(r)
// Super/global admins can assign any org role.
if !auth.IsSuperAdminRole(callerRole) && callerRole != auth.RoleGlobalAdmin {
callerOrgUser, _ := s.db.GetOrgUserByUsername(orgID, callerUsername)
if callerOrgUser == nil {
http.Error(w, `{"error":"you are not a member of this organization"}`, http.StatusForbidden)
return
}
if !db.OrgCanAssignRole(callerOrgUser.Role, body.Role) {
http.Error(w, `{"error":"cannot assign a role higher than your org-level authority"}`, http.StatusForbidden)
return
}
}
// Check duplicate
existing, _ := s.db.GetOrgUserByUsername(orgID, body.Username)
if existing != nil {
@@ -306,6 +342,24 @@ func (s *Server) handleListOrgUsers(w http.ResponseWriter, r *http.Request) {
if users == nil {
users = []*db.OrgUser{}
}
// Org User scope: can only see themselves.
callerRole := getRoleFromCtx(r)
if !auth.IsSuperAdminRole(callerRole) && callerRole != auth.RoleGlobalAdmin {
callerUsername := getUsernameFromCtx(r)
callerOrgUser, _ := s.db.GetOrgUserByUsername(orgID, callerUsername)
if callerOrgUser != nil && callerOrgUser.Role == db.OrgRoleUser {
filtered := []*db.OrgUser{}
for _, u := range users {
if u.Username == callerUsername {
filtered = append(filtered, u)
break
}
}
users = filtered
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"users": users})
}
@@ -342,11 +396,38 @@ func (s *Server) handleUpdateOrgUser(w http.ResponseWriter, r *http.Request) {
user.Email = *body.Email
}
if body.Role != nil {
if *body.Role != db.OrgRoleOwner && *body.Role != db.OrgRoleAdmin &&
*body.Role != db.OrgRoleOperator && *body.Role != db.OrgRoleUser {
if !db.ValidOrgRole(*body.Role) {
http.Error(w, `{"error":"invalid role"}`, http.StatusBadRequest)
return
}
// Self-modification block: cannot change own org role.
callerUsername := getUsernameFromCtx(r)
if user.Username == callerUsername {
http.Error(w, `{"error":"cannot modify your own org role"}`, http.StatusForbidden)
return
}
// Org role boundary: check caller's authority.
callerRole := getRoleFromCtx(r)
if !auth.IsSuperAdminRole(callerRole) && callerRole != auth.RoleGlobalAdmin {
callerOrgUser, _ := s.db.GetOrgUserByUsername(user.OrgID, callerUsername)
if callerOrgUser == nil {
http.Error(w, `{"error":"you are not a member of this organization"}`, http.StatusForbidden)
return
}
// Cannot promote higher than own org role.
if !db.OrgCanAssignRole(callerOrgUser.Role, *body.Role) {
http.Error(w, `{"error":"cannot assign a role higher than your org-level authority"}`, http.StatusForbidden)
return
}
// Cannot demote someone at or above own level.
if db.OrgRoleLevel(user.Role) >= db.OrgRoleLevel(callerOrgUser.Role) {
http.Error(w, `{"error":"cannot modify a user at or above your org-level authority"}`, http.StatusForbidden)
return
}
}
user.Role = *body.Role
}
@@ -636,13 +717,13 @@ func (s *Server) handleOrgLogin(w http.ResponseWriter, r *http.Request) {
// Update last login
s.db.UpdateOrgUserLogin(user.ID)
// Generate JWT
// Generate JWT with org context
if s.jwtManager == nil {
http.Error(w, `{"error":"JWT not configured"}`, http.StatusInternalServerError)
return
}
token, err := s.jwtManager.Generate(user.Username, user.Role)
token, err := s.jwtManager.GenerateOrgToken(user.Username, user.Role, orgID, s.jwtManager.Expiry())
if err != nil {
log.Printf("[org] JWT generation error: %v", err)
http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError)
@@ -651,9 +732,9 @@ func (s *Server) handleOrgLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"token": token,
"user": user,
"org_id": orgID,
"type": "org_user",
"token": token,
"user": user,
"org_id": orgID,
"type": "org_user",
})
}
+86 -53
View File
@@ -127,14 +127,14 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("GET /api/server/stats", s.handleServerStats)
mux.HandleFunc("GET /api/server/pubkey", s.handlePubKey)
// Peers
mux.HandleFunc("GET /api/peers", s.handleListPeers)
mux.HandleFunc("GET /api/peers/{id}", s.handleGetPeer)
mux.HandleFunc("DELETE /api/peers/{id}", s.requireRole(auth.RoleAdmin, s.handleDeletePeer))
mux.HandleFunc("PATCH /api/peers/{id}", s.handleUpdatePeerFields)
mux.HandleFunc("POST /api/peers/{id}/ban", s.requireRole(auth.RoleAdmin, s.handleBanPeer))
mux.HandleFunc("POST /api/peers/{id}/unban", s.requireRole(auth.RoleAdmin, s.handleUnbanPeer))
mux.HandleFunc("POST /api/peers/{id}/change-id", s.requireRole(auth.RoleAdmin, s.handleChangePeerID))
// Peers (permission-based access control)
mux.HandleFunc("GET /api/peers", s.requirePermission(auth.PermDeviceView, s.handleListPeers))
mux.HandleFunc("GET /api/peers/{id}", s.requirePermission(auth.PermDeviceView, s.handleGetPeer))
mux.HandleFunc("DELETE /api/peers/{id}", s.requirePermission(auth.PermDeviceDelete, s.handleDeletePeer))
mux.HandleFunc("PATCH /api/peers/{id}", s.requirePermission(auth.PermDeviceEdit, s.handleUpdatePeerFields))
mux.HandleFunc("POST /api/peers/{id}/ban", s.requirePermission(auth.PermDeviceBan, s.handleBanPeer))
mux.HandleFunc("POST /api/peers/{id}/unban", s.requirePermission(auth.PermDeviceBan, s.handleUnbanPeer))
mux.HandleFunc("POST /api/peers/{id}/change-id", s.requirePermission(auth.PermDeviceChangeID, s.handleChangePeerID))
// Detailed device status (enhanced in Phase 4)
mux.HandleFunc("GET /api/peers/status/summary", s.handleStatusSummary)
@@ -148,42 +148,42 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("DELETE /api/peers/{id}/access-policy", s.requireRole(auth.RoleAdmin, s.handleDeleteAccessPolicy))
// Blocklist management
mux.HandleFunc("GET /api/blocklist", s.handleListBlocklist)
mux.HandleFunc("POST /api/blocklist", s.requireRole(auth.RoleAdmin, s.handleAddBlocklist))
mux.HandleFunc("DELETE /api/blocklist/{entry}", s.requireRole(auth.RoleAdmin, s.handleRemoveBlocklist))
mux.HandleFunc("GET /api/blocklist", s.requirePermission(auth.PermBlocklistEdit, s.handleListBlocklist))
mux.HandleFunc("POST /api/blocklist", s.requirePermission(auth.PermBlocklistEdit, s.handleAddBlocklist))
mux.HandleFunc("DELETE /api/blocklist/{entry}", s.requirePermission(auth.PermBlocklistEdit, s.handleRemoveBlocklist))
// Tags
mux.HandleFunc("PUT /api/peers/{id}/tags", s.handleSetPeerTags)
mux.HandleFunc("GET /api/tags/{tag}/peers", s.handlePeersByTag)
mux.HandleFunc("PUT /api/peers/{id}/tags", s.requirePermission(auth.PermDeviceEdit, s.handleSetPeerTags))
mux.HandleFunc("GET /api/tags/{tag}/peers", s.requirePermission(auth.PermDeviceView, s.handlePeersByTag))
// Chat
mux.HandleFunc("GET /api/chat/history/", s.handleChatHistory)
mux.HandleFunc("POST /api/chat/messages", s.handleChatSendMessage)
mux.HandleFunc("POST /api/chat/read", s.handleChatMarkRead)
mux.HandleFunc("GET /api/chat/unread/", s.handleChatUnread)
mux.HandleFunc("GET /api/chat/contacts/", s.handleChatContacts)
mux.HandleFunc("POST /api/chat/groups", s.handleChatCreateGroup)
mux.HandleFunc("GET /api/chat/groups/", s.handleChatListGroups)
mux.HandleFunc("PUT /api/chat/groups/", s.handleChatUpdateGroup)
mux.HandleFunc("DELETE /api/chat/groups/", s.handleChatDeleteGroup)
// Chat (requires chat.access permission)
mux.HandleFunc("GET /api/chat/history/", s.requirePermission(auth.PermChatAccess, s.handleChatHistory))
mux.HandleFunc("POST /api/chat/messages", s.requirePermission(auth.PermChatAccess, s.handleChatSendMessage))
mux.HandleFunc("POST /api/chat/read", s.requirePermission(auth.PermChatAccess, s.handleChatMarkRead))
mux.HandleFunc("GET /api/chat/unread/", s.requirePermission(auth.PermChatAccess, s.handleChatUnread))
mux.HandleFunc("GET /api/chat/contacts/", s.requirePermission(auth.PermChatAccess, s.handleChatContacts))
mux.HandleFunc("POST /api/chat/groups", s.requirePermission(auth.PermChatAccess, s.handleChatCreateGroup))
mux.HandleFunc("GET /api/chat/groups/", s.requirePermission(auth.PermChatAccess, s.handleChatListGroups))
mux.HandleFunc("PUT /api/chat/groups/", s.requirePermission(auth.PermChatAccess, s.handleChatUpdateGroup))
mux.HandleFunc("DELETE /api/chat/groups/", s.requirePermission(auth.PermChatAccess, s.handleChatDeleteGroup))
// Organizations (v3.0.0)
mux.HandleFunc("POST /api/org", s.requireRole(auth.RoleAdmin, s.handleCreateOrg))
mux.HandleFunc("GET /api/org", s.handleListOrgs)
mux.HandleFunc("GET /api/org/{id}", s.handleGetOrg)
mux.HandleFunc("PUT /api/org/{id}", s.requireRole(auth.RoleAdmin, s.handleUpdateOrg))
mux.HandleFunc("DELETE /api/org/{id}", s.requireRole(auth.RoleAdmin, s.handleDeleteOrg))
mux.HandleFunc("GET /api/org/{id}/users", s.handleListOrgUsers)
mux.HandleFunc("POST /api/org/{id}/users", s.requireRole(auth.RoleAdmin, s.handleCreateOrgUser))
mux.HandleFunc("PUT /api/org/{id}/users/{uid}", s.requireRole(auth.RoleAdmin, s.handleUpdateOrgUser))
mux.HandleFunc("DELETE /api/org/{id}/users/{uid}", s.requireRole(auth.RoleAdmin, s.handleDeleteOrgUser))
mux.HandleFunc("POST /api/org/{id}/invite", s.requireRole(auth.RoleAdmin, s.handleCreateOrgInvitation))
mux.HandleFunc("GET /api/org/{id}/invitations", s.requireRole(auth.RoleAdmin, s.handleListOrgInvitations))
mux.HandleFunc("POST /api/org/{id}/devices", s.requireRole(auth.RoleOperator, s.handleAssignOrgDevice))
mux.HandleFunc("GET /api/org/{id}/devices", s.handleListOrgDevices)
mux.HandleFunc("DELETE /api/org/{id}/devices/{did}", s.requireRole(auth.RoleOperator, s.handleUnassignOrgDevice))
mux.HandleFunc("GET /api/org/{id}/settings", s.handleListOrgSettings)
mux.HandleFunc("PUT /api/org/{id}/settings", s.requireRole(auth.RoleAdmin, s.handleSetOrgSetting))
// Organizations — org membership enforced on org-specific routes
mux.HandleFunc("POST /api/org", s.requirePermission(auth.PermOrgCreate, s.handleCreateOrg))
mux.HandleFunc("GET /api/org", s.handleListOrgs) // data-scoped in handler
mux.HandleFunc("GET /api/org/{id}", s.requireOrgMembership("id", s.handleGetOrg))
mux.HandleFunc("PUT /api/org/{id}", s.requirePermission(auth.PermOrgEdit, s.requireOrgMembership("id", s.handleUpdateOrg)))
mux.HandleFunc("DELETE /api/org/{id}", s.requirePermission(auth.PermOrgDelete, s.handleDeleteOrg))
mux.HandleFunc("GET /api/org/{id}/users", s.requireOrgMembership("id", s.handleListOrgUsers))
mux.HandleFunc("POST /api/org/{id}/users", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleCreateOrgUser)))
mux.HandleFunc("PUT /api/org/{id}/users/{uid}", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleUpdateOrgUser)))
mux.HandleFunc("DELETE /api/org/{id}/users/{uid}", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleDeleteOrgUser)))
mux.HandleFunc("POST /api/org/{id}/invite", s.requirePermission(auth.PermOrgManageUsers, s.requireOrgMembership("id", s.handleCreateOrgInvitation)))
mux.HandleFunc("GET /api/org/{id}/invitations", s.requireOrgMembership("id", s.handleListOrgInvitations))
mux.HandleFunc("POST /api/org/{id}/devices", s.requirePermission(auth.PermOrgManageDevices, s.requireOrgMembership("id", s.handleAssignOrgDevice)))
mux.HandleFunc("GET /api/org/{id}/devices", s.requireOrgMembership("id", s.handleListOrgDevices))
mux.HandleFunc("DELETE /api/org/{id}/devices/{did}", s.requirePermission(auth.PermOrgManageDevices, s.requireOrgMembership("id", s.handleUnassignOrgDevice)))
mux.HandleFunc("GET /api/org/{id}/settings", s.requireOrgMembership("id", s.handleListOrgSettings))
mux.HandleFunc("PUT /api/org/{id}/settings", s.requirePermission(auth.PermOrgEdit, s.requireOrgMembership("id", s.handleSetOrgSetting)))
mux.HandleFunc("POST /api/org/login", s.handleOrgLogin) // public — no auth required
// Audit
@@ -192,9 +192,9 @@ func (s *Server) Start(ctx context.Context) error {
// WebSocket real-time events
mux.HandleFunc("GET /api/ws/events", s.handleWSEvents)
// Config
mux.HandleFunc("GET /api/config/{key}", s.requireRole(auth.RoleAdmin, s.handleGetConfig))
mux.HandleFunc("PUT /api/config/{key}", s.requireRole(auth.RoleAdmin, s.handleSetConfig))
// Config (server.config permission)
mux.HandleFunc("GET /api/config/{key}", s.requirePermission(auth.PermServerConfig, s.handleGetConfig))
mux.HandleFunc("PUT /api/config/{key}", s.requirePermission(auth.PermServerConfig, s.handleSetConfig))
// Auth (public — no auth required, handled by middleware exclusion)
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
@@ -217,21 +217,21 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("POST /api/sysinfo", s.handleClientSysinfo)
mux.HandleFunc("POST /api/sysinfo_ver", s.handleClientSysinfoVer)
// User management (admin only)
mux.HandleFunc("GET /api/users", s.requireRole(auth.RoleAdmin, s.handleListUsers))
mux.HandleFunc("POST /api/users", s.requireRole(auth.RoleAdmin, s.handleCreateUser))
mux.HandleFunc("PUT /api/users/{id}", s.requireRole(auth.RoleAdmin, s.handleUpdateUser))
mux.HandleFunc("DELETE /api/users/{id}", s.requireRole(auth.RoleAdmin, s.handleDeleteUser))
// User management (permission-based)
mux.HandleFunc("GET /api/users", s.requirePermission(auth.PermUserView, s.handleListUsers))
mux.HandleFunc("POST /api/users", s.requirePermission(auth.PermUserCreate, s.handleCreateUser))
mux.HandleFunc("PUT /api/users/{id}", s.requirePermission(auth.PermUserEdit, s.handleUpdateUser))
mux.HandleFunc("DELETE /api/users/{id}", s.requirePermission(auth.PermUserDelete, s.handleDeleteUser))
// TOTP management (admin only)
mux.HandleFunc("POST /api/users/{id}/totp/setup", s.requireRole(auth.RoleAdmin, s.handleSetupTOTP))
mux.HandleFunc("POST /api/users/{id}/totp/confirm", s.requireRole(auth.RoleAdmin, s.handleConfirmTOTP))
mux.HandleFunc("DELETE /api/users/{id}/totp", s.requireRole(auth.RoleAdmin, s.handleDisableTOTP))
// API key management (admin only)
mux.HandleFunc("GET /api/keys", s.requireRole(auth.RoleAdmin, s.handleListAPIKeys))
mux.HandleFunc("POST /api/keys", s.requireRole(auth.RoleAdmin, s.handleCreateAPIKey))
mux.HandleFunc("DELETE /api/keys/{id}", s.requireRole(auth.RoleAdmin, s.handleDeleteAPIKey))
// API key management (server.keys permission)
mux.HandleFunc("GET /api/keys", s.requirePermission(auth.PermServerKeys, s.handleListAPIKeys))
mux.HandleFunc("POST /api/keys", s.requirePermission(auth.PermServerKeys, s.handleCreateAPIKey))
mux.HandleFunc("DELETE /api/keys/{id}", s.requirePermission(auth.PermServerKeys, s.handleDeleteAPIKey))
// Device token management (Dual Key System - admin only)
mux.HandleFunc("GET /api/tokens", s.requireRole(auth.RoleAdmin, s.handleListDeviceTokens))
@@ -451,7 +451,16 @@ func (s *Server) handleServerStats(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
includeDeleted := r.URL.Query().Get("include_deleted") == "true"
peers, err := s.db.ListPeers(includeDeleted)
// Data scoping: org-scoped users only see their org's devices
orgID := getOrgIDFromCtx(r)
var peers []*db.Peer
var err error
if orgID != "" {
peers, err = s.db.ListPeersForOrg(orgID, includeDeleted)
} else {
peers, err = s.db.ListPeers(includeDeleted)
}
if err != nil {
writeInternalError(w, err, "ListPeers")
return
@@ -495,6 +504,12 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// Org scope check: org-scoped users can only access devices in their org.
if !s.peerOrgScopeCheck(w, r, id) {
return
}
p, err := s.db.GetPeer(id)
if err != nil {
writeInternalError(w, err, "GetPeer")
@@ -557,6 +572,9 @@ func (s *Server) handleLinkedPeers(w http.ResponseWriter, r *http.Request) {
// PATCH /api/peers/{id}
func (s *Server) handleUpdatePeerFields(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, id) {
return
}
var body struct {
Note *string `json:"note"`
@@ -602,6 +620,9 @@ func (s *Server) handleUpdatePeerFields(w http.ResponseWriter, r *http.Request)
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, id) {
return
}
hard := r.URL.Query().Get("hard") == "true"
revoke := r.URL.Query().Get("revoke") == "true"
cascade := r.URL.Query().Get("cascade") == "true"
@@ -710,6 +731,9 @@ func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleBanPeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, id) {
return
}
var body struct {
Reason string `json:"reason"`
@@ -737,6 +761,9 @@ func (s *Server) handleBanPeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleUnbanPeer(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, id) {
return
}
if err := s.db.UnbanPeer(id); err != nil {
writeInternalError(w, err, "UnbanPeer")
return
@@ -756,6 +783,9 @@ func (s *Server) handleUnbanPeer(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleChangePeerID(w http.ResponseWriter, r *http.Request) {
oldID := r.PathValue("id")
if !s.peerOrgScopeCheck(w, r, oldID) {
return
}
var body struct {
NewID string `json:"new_id"`
@@ -912,6 +942,9 @@ func (s *Server) handlePeerMetrics(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid peer ID"})
return
}
if !s.peerOrgScopeCheck(w, r, id) {
return
}
// Parse optional limit param (default 100, max 1000)
limit := 100
+18 -10
View File
@@ -13,11 +13,12 @@ import (
// Claims represents the JWT payload.
type Claims struct {
Sub string `json:"sub"` // Subject (username)
Role string `json:"role"` // User role (admin, operator, viewer)
Iat int64 `json:"iat"` // Issued at (Unix)
Exp int64 `json:"exp"` // Expires at (Unix)
Jti string `json:"jti,omitempty"` // JWT ID (for revocation)
Sub string `json:"sub"` // Subject (username)
Role string `json:"role"` // User role (admin, operator, viewer)
OrgID string `json:"org_id,omitempty"` // Organization ID (empty = global/server-level user)
Iat int64 `json:"iat"` // Issued at (Unix)
Exp int64 `json:"exp"` // Expires at (Unix)
Jti string `json:"jti,omitempty"` // JWT ID (for revocation)
}
// JWTManager generates and validates HS256 JWT tokens.
@@ -52,17 +53,24 @@ func (m *JWTManager) Generate(subject, role string) (string, error) {
// GenerateWithTTL creates a new signed JWT token with a custom time-to-live.
// This is used for short-lived tokens such as partial 2FA tokens (H4).
func (m *JWTManager) GenerateWithTTL(subject, role string, ttl time.Duration) (string, error) {
return m.GenerateOrgToken(subject, role, "", ttl)
}
// GenerateOrgToken creates a signed JWT token with an organization context.
// If orgID is empty, the token is a global/server-level token.
func (m *JWTManager) GenerateOrgToken(subject, role, orgID string, ttl time.Duration) (string, error) {
jti, err := GenerateRandomString(16)
if err != nil {
return "", fmt.Errorf("auth: generate jti: %w", err)
}
now := time.Now().Unix()
claims := Claims{
Sub: subject,
Role: role,
Iat: now,
Exp: now + int64(ttl.Seconds()),
Jti: jti,
Sub: subject,
Role: role,
OrgID: orgID,
Iat: now,
Exp: now + int64(ttl.Seconds()),
Jti: jti,
}
hdr := b64URLEncode(mustJSON(jwtHeader{Alg: "HS256", Typ: "JWT"}))
+162
View File
@@ -0,0 +1,162 @@
// Package auth — granular permission system for RBAC (Phase 52).
//
// Each permission is a dot-separated action string: "resource.action".
// Roles map to a set of granted permissions via DefaultRolePermissions.
// Custom overrides are stored in the role_permissions DB table.
package auth
// Permission constants define every discrete action in the system.
const (
// Device permissions
PermDeviceView = "device.view"
PermDeviceConnect = "device.connect"
PermDeviceEdit = "device.edit" // notes, tags, display name
PermDeviceDelete = "device.delete" // soft-delete + revoke
PermDeviceBan = "device.ban" // ban/unban
PermDeviceChangeID = "device.change_id"
// User management permissions
PermUserView = "user.view"
PermUserCreate = "user.create"
PermUserEdit = "user.edit"
PermUserDelete = "user.delete"
// Server configuration
PermServerConfig = "server.config" // read/write server_config
PermServerKeys = "server.keys" // manage API keys
// Organization permissions
PermOrgCreate = "org.create"
PermOrgEdit = "org.edit"
PermOrgDelete = "org.delete"
PermOrgManageUsers = "org.manage_users"
PermOrgManageDevices = "org.manage_devices"
// Audit + monitoring
PermAuditView = "audit.view"
PermMetricsView = "metrics.view"
PermBlocklistEdit = "blocklist.edit"
// CDAP
PermCDAPView = "cdap.view"
PermCDAPCommand = "cdap.command"
PermCDAPTerminal = "cdap.terminal"
PermCDAPFiles = "cdap.files"
// Enrollment
PermEnrollmentManage = "enrollment.manage"
PermEnrollmentApprove = "enrollment.approve"
// Chat
PermChatAccess = "chat.access"
// Branding
PermBrandingEdit = "branding.edit"
)
// AllPermissions is the complete list of permission strings for validation.
var AllPermissions = []string{
PermDeviceView, PermDeviceConnect, PermDeviceEdit, PermDeviceDelete,
PermDeviceBan, PermDeviceChangeID,
PermUserView, PermUserCreate, PermUserEdit, PermUserDelete,
PermServerConfig, PermServerKeys,
PermOrgCreate, PermOrgEdit, PermOrgDelete, PermOrgManageUsers, PermOrgManageDevices,
PermAuditView, PermMetricsView, PermBlocklistEdit,
PermCDAPView, PermCDAPCommand, PermCDAPTerminal, PermCDAPFiles,
PermEnrollmentManage, PermEnrollmentApprove,
PermChatAccess,
PermBrandingEdit,
}
// DefaultRolePermissions maps each built-in role to its default set of permissions.
// Custom overrides from the DB take precedence.
//
// Role scoping (Discussion #99):
// super_admin — all permissions, manages other super admins
// server_admin — server infrastructure only, read-only user list
// global_admin — all-org user/device/org management, NO server access
// admin — legacy alias, equivalent to super_admin
// operator — day-to-day device ops + chat
// viewer — read-only dashboards
// pro — API-only device view
var DefaultRolePermissions = map[string]map[string]bool{
RoleSuperAdmin: buildPermMap(AllPermissions),
RoleAdmin: buildPermMap(AllPermissions), // legacy admin = super_admin
// Server Admin: infrastructure + monitoring, read-only user visibility.
// Cannot create/edit/delete users, cannot manage orgs.
RoleServerAdmin: buildPermMap([]string{
PermServerConfig, PermServerKeys,
PermBlocklistEdit,
PermUserView, // read-only
PermDeviceView, // read-only
PermAuditView,
PermMetricsView,
PermEnrollmentManage,
}),
// Global Admin: all user/org management, NO server config/keys.
RoleGlobalAdmin: buildPermMap([]string{
PermUserView, PermUserCreate, PermUserEdit, PermUserDelete,
PermOrgCreate, PermOrgEdit, PermOrgDelete, PermOrgManageUsers, PermOrgManageDevices,
PermDeviceView, PermDeviceConnect, PermDeviceEdit, PermDeviceDelete,
PermDeviceBan, PermDeviceChangeID,
PermAuditView, PermMetricsView,
PermCDAPView, PermCDAPCommand,
PermChatAccess,
PermEnrollmentManage, PermEnrollmentApprove,
PermBrandingEdit,
}),
RoleOperator: buildPermMap([]string{
PermDeviceView, PermDeviceConnect, PermDeviceEdit,
PermUserView,
PermAuditView, PermMetricsView,
PermCDAPView, PermCDAPCommand,
PermEnrollmentApprove,
PermChatAccess,
PermOrgManageDevices,
}),
RoleViewer: buildPermMap([]string{
PermDeviceView,
PermAuditView, PermMetricsView,
PermCDAPView,
PermChatAccess,
}),
RolePro: buildPermMap([]string{
PermDeviceView,
}),
}
// buildPermMap converts a slice of permission strings into a lookup map.
func buildPermMap(perms []string) map[string]bool {
m := make(map[string]bool, len(perms))
for _, p := range perms {
m[p] = true
}
return m
}
// RoleHasPermission checks whether a role (by name) has a specific permission
// according to default role mappings. Returns true for super_admin and legacy admin.
// For DB-overridden permissions, use the Database.HasRolePermission method instead.
func RoleHasPermission(role, permission string) bool {
if IsSuperAdminRole(role) {
return true
}
perms, ok := DefaultRolePermissions[role]
if !ok {
return false
}
return perms[permission]
}
// ValidPermission returns true if the given string is a recognized permission.
func ValidPermission(p string) bool {
for _, v := range AllPermissions {
if v == p {
return true
}
}
return false
}
+65 -8
View File
@@ -1,37 +1,94 @@
package auth
// Role constants define the permission hierarchy.
// admin > operator > viewer > pro
// Server-level role constants (global scope).
//
// Hierarchy (branched — not strictly linear):
//
// super_admin — full server + all-org access, manages other super admins
// ├── server_admin — server config/logs/integrations, read-only user visibility
// ├── global_admin — all-org user/device management, no server access
// └── (legacy) admin/operator/viewer/pro — kept for backward compatibility
const (
RoleAdmin = "admin"
// New 6-tier roles (Discussion #99)
RoleSuperAdmin = "super_admin"
RoleServerAdmin = "server_admin"
RoleGlobalAdmin = "global_admin"
// Legacy global roles (backward-compatible)
RoleAdmin = "admin" // maps to super_admin in permission terms
RoleOperator = "operator"
RoleViewer = "viewer"
RolePro = "pro" // API-only, no web panel access
)
// RoleLevel returns the numeric privilege level for a role.
// Higher = more privileges.
// Higher = more privileges. server_admin and global_admin share level 4
// but have DIFFERENT permission sets — use RoleHasPermission for checks.
func RoleLevel(role string) int {
switch role {
case RoleAdmin:
return 3
case RoleSuperAdmin:
return 5
case RoleServerAdmin, RoleGlobalAdmin:
return 4
case RoleAdmin: // legacy admin ≈ super_admin
return 5
case RoleOperator:
return 2
case RoleViewer:
return 1
case RolePro:
return 0 // API-only, lowest privilege
return 0
default:
return 0
}
}
// IsSuperAdminRole returns true for super_admin and legacy admin.
func IsSuperAdminRole(role string) bool {
return role == RoleSuperAdmin || role == RoleAdmin
}
// IsServerLevel returns true for any server-level elevated role.
func IsServerLevel(role string) bool {
return role == RoleSuperAdmin || role == RoleAdmin ||
role == RoleServerAdmin || role == RoleGlobalAdmin
}
// CanAssignRole checks whether a user with callerRole may assign targetRole.
// Implements the role assignment boundary rules from Discussion #99.
func CanAssignRole(callerRole, targetRole string) bool {
switch {
// Super Admin (and legacy admin) can assign ANY role
case IsSuperAdminRole(callerRole):
return true
// Global Admin can assign roles below global_admin
// (operator, viewer, pro — NOT super_admin, server_admin, global_admin, admin)
case callerRole == RoleGlobalAdmin:
return targetRole == RoleOperator || targetRole == RoleViewer || targetRole == RolePro
// Server Admin cannot assign any roles
case callerRole == RoleServerAdmin:
return false
// Operator, viewer, pro cannot assign any roles
default:
return false
}
}
// HasPermission returns true if userRole has at least the privileges of requiredRole.
// Kept for backward compatibility — prefer requirePermission middleware.
func HasPermission(userRole, requiredRole string) bool {
return RoleLevel(userRole) >= RoleLevel(requiredRole)
}
// ValidRole returns true if the given string is a recognised role.
func ValidRole(r string) bool {
return r == RoleAdmin || r == RoleOperator || r == RoleViewer || r == RolePro
switch r {
case RoleSuperAdmin, RoleServerAdmin, RoleGlobalAdmin,
RoleAdmin, RoleOperator, RoleViewer, RolePro:
return true
}
return false
}
+64 -8
View File
@@ -40,14 +40,25 @@ type ServerConfig struct {
// User represents an API user account.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Role string `json:"role"` // admin, operator, viewer
TOTPSecret string `json:"-"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login,omitempty"`
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Role string `json:"role"` // admin, operator, viewer
IsServerAdmin bool `json:"is_server_admin"` // Phase 3: separate server admin flag
TOTPSecret string `json:"-"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt string `json:"created_at"`
LastLogin string `json:"last_login,omitempty"`
}
// RolePermission represents a custom permission override for a role.
// Stored in the role_permissions table. If no override exists for a role+permission,
// the default from auth.DefaultRolePermissions is used.
type RolePermission struct {
ID int64 `json:"id"`
Role string `json:"role"`
Permission string `json:"permission"` // e.g. "device.view", "user.manage"
Granted bool `json:"granted"` // true = allowed, false = denied
}
// APIKey represents a scoped API key for programmatic access.
@@ -218,6 +229,42 @@ const (
OrgRoleUser = "user"
)
// OrgRoleLevel returns the numeric privilege level for an org-scoped role.
// Higher = more privileges. Used for role boundary enforcement.
func OrgRoleLevel(role string) int {
switch role {
case OrgRoleOwner:
return 40
case OrgRoleAdmin:
return 30
case OrgRoleOperator:
return 20
case OrgRoleUser:
return 10
default:
return 0
}
}
// OrgCanAssignRole checks whether a caller with callerOrgRole may assign targetOrgRole.
// Owner → any; Admin → operator, user; Operator/User → none.
func OrgCanAssignRole(callerOrgRole, targetOrgRole string) bool {
switch callerOrgRole {
case OrgRoleOwner:
// Owner can assign admin, operator, user (not another owner — handled in API)
return targetOrgRole == OrgRoleAdmin || targetOrgRole == OrgRoleOperator || targetOrgRole == OrgRoleUser
case OrgRoleAdmin:
return targetOrgRole == OrgRoleOperator || targetOrgRole == OrgRoleUser
default:
return false
}
}
// ValidOrgRole returns true if the given string is a recognized org role.
func ValidOrgRole(r string) bool {
return r == OrgRoleOwner || r == OrgRoleAdmin || r == OrgRoleOperator || r == OrgRoleUser
}
// Database is the interface for all database operations.
// Designed to support SQLite (now) and PostgreSQL (future) as drop-in implementations.
type Database interface {
@@ -365,4 +412,13 @@ type Database interface {
GetAccessPolicy(peerID string) (*AccessPolicy, error)
SaveAccessPolicy(p *AccessPolicy) error
DeleteAccessPolicy(peerID string) error
// Role Permissions (RBAC Phase 52)
ListRolePermissions(role string) ([]*RolePermission, error)
SetRolePermission(role, permission string, granted bool) error
DeleteRolePermission(role, permission string) error
HasRolePermission(role, permission string) (bool, error)
// Org-scoped device queries (RBAC Phase 52 — data scoping)
ListPeersForOrg(orgID string, includeDeleted bool) ([]*Peer, error)
}
+106 -9
View File
@@ -88,13 +88,15 @@ func (pg *PostgresDB) Migrate() error {
deleted_at TIMESTAMPTZ,
note TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
heartbeat_seq BIGINT NOT NULL DEFAULT 0
heartbeat_seq BIGINT NOT NULL DEFAULT 0,
device_type TEXT NOT NULL DEFAULT '',
linked_peer_id TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_peers_uuid ON peers(uuid)`,
`CREATE INDEX IF NOT EXISTS idx_peers_status ON peers(status)`,
`CREATE INDEX IF NOT EXISTS idx_peers_banned ON peers(banned) WHERE banned = TRUE`,
`CREATE INDEX IF NOT EXISTS idx_peers_soft_deleted ON peers(soft_deleted) WHERE soft_deleted = FALSE`,
`CREATE INDEX IF NOT EXISTS idx_peers_linked_peer ON peers(linked_peer_id) WHERE linked_peer_id != ''`,
`CREATE TABLE IF NOT EXISTS server_config (
key TEXT PRIMARY KEY,
@@ -259,6 +261,16 @@ func (pg *PostgresDB) Migrate() error {
value TEXT NOT NULL DEFAULT '',
PRIMARY KEY(org_id, key)
)`,
// Role permission overrides (RBAC Phase 52)
`CREATE TABLE IF NOT EXISTS role_permissions (
id BIGSERIAL PRIMARY KEY,
role TEXT NOT NULL,
permission TEXT NOT NULL,
granted BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE(role, permission)
)`,
`CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role)`,
`CREATE TABLE IF NOT EXISTS access_policies (
peer_id TEXT PRIMARY KEY,
unattended_enabled BOOLEAN NOT NULL DEFAULT FALSE,
@@ -300,6 +312,8 @@ func (pg *PostgresDB) Migrate() error {
`ALTER TABLE peers ADD COLUMN IF NOT EXISTS linked_peer_id TEXT NOT NULL DEFAULT ''`,
// peers: display_name alias (added in v2.6.0)
`ALTER TABLE peers ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT ''`,
// users: server admin flag (RBAC Phase 52)
`ALTER TABLE users ADD COLUMN IF NOT EXISTS is_server_admin BOOLEAN NOT NULL DEFAULT FALSE`,
}
for _, ddl := range columnMigrations {
@@ -308,6 +322,17 @@ func (pg *PostgresDB) Migrate() error {
}
}
// Deferred indexes — must run AFTER column migrations so that columns
// like linked_peer_id exist on databases created before v2.5.0.
deferredIndexes := []string{
`CREATE INDEX IF NOT EXISTS idx_peers_linked_peer ON peers(linked_peer_id) WHERE linked_peer_id != ''`,
}
for _, idx := range deferredIndexes {
if _, err := pg.pool.Exec(pg.ctx, idx); err != nil {
return fmt.Errorf("db: PostgreSQL deferred index failed: %w\nStatement: %s", err, idx)
}
}
return nil
}
@@ -782,7 +807,7 @@ func scanUser(row pgx.Row) (*User, error) {
var lastLogin *time.Time
err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
&u.TOTPSecret, &u.TOTPEnabled, &createdAt, &lastLogin)
&u.TOTPSecret, &u.TOTPEnabled, &createdAt, &lastLogin, &u.IsServerAdmin)
if err != nil {
return nil, err
}
@@ -801,7 +826,7 @@ func scanUser(row pgx.Row) (*User, error) {
func (pg *PostgresDB) GetUser(username string) (*User, error) {
row := pg.pool.QueryRow(pg.ctx,
`SELECT id, username, password_hash, role, totp_secret, totp_enabled,
created_at, last_login FROM users WHERE username = $1`, username)
created_at, last_login, COALESCE(is_server_admin, FALSE) FROM users WHERE username = $1`, username)
u, err := scanUser(row)
if err == pgx.ErrNoRows {
return nil, nil
@@ -813,7 +838,7 @@ func (pg *PostgresDB) GetUser(username string) (*User, error) {
func (pg *PostgresDB) GetUserByID(id int64) (*User, error) {
row := pg.pool.QueryRow(pg.ctx,
`SELECT id, username, password_hash, role, totp_secret, totp_enabled,
created_at, last_login FROM users WHERE id = $1`, id)
created_at, last_login, COALESCE(is_server_admin, FALSE) FROM users WHERE id = $1`, id)
u, err := scanUser(row)
if err == pgx.ErrNoRows {
return nil, nil
@@ -825,7 +850,7 @@ func (pg *PostgresDB) GetUserByID(id int64) (*User, error) {
func (pg *PostgresDB) ListUsers() ([]*User, error) {
rows, err := pg.pool.Query(pg.ctx,
`SELECT id, username, password_hash, role, totp_secret, totp_enabled,
created_at, last_login FROM users ORDER BY id`)
created_at, last_login, COALESCE(is_server_admin, FALSE) FROM users ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("db: ListUsers: %w", err)
}
@@ -845,9 +870,9 @@ func (pg *PostgresDB) ListUsers() ([]*User, error) {
// UpdateUser updates a user's mutable fields.
func (pg *PostgresDB) UpdateUser(u *User) error {
_, err := pg.pool.Exec(pg.ctx,
`UPDATE users SET password_hash = $1, role = $2, totp_secret = $3, totp_enabled = $4
WHERE id = $5`,
u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled, u.ID)
`UPDATE users SET password_hash = $1, role = $2, totp_secret = $3, totp_enabled = $4, is_server_admin = $5
WHERE id = $6`,
u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled, u.IsServerAdmin, u.ID)
return err
}
@@ -1518,3 +1543,75 @@ func (pg *PostgresDB) DeleteAccessPolicy(peerID string) error {
_, err := pg.pool.Exec(pg.ctx, `DELETE FROM access_policies WHERE peer_id = $1`, peerID)
return err
}
// --- Role Permissions (RBAC Phase 52) ---
func (pg *PostgresDB) ListRolePermissions(role string) ([]*RolePermission, error) {
rows, err := pg.pool.Query(pg.ctx,
`SELECT id, role, permission, granted FROM role_permissions WHERE role = $1`, role)
if err != nil {
return nil, fmt.Errorf("db: ListRolePermissions: %w", err)
}
defer rows.Close()
var perms []*RolePermission
for rows.Next() {
p := &RolePermission{}
if err := rows.Scan(&p.ID, &p.Role, &p.Permission, &p.Granted); err != nil {
return nil, err
}
perms = append(perms, p)
}
return perms, rows.Err()
}
func (pg *PostgresDB) SetRolePermission(role, permission string, granted bool) error {
_, err := pg.pool.Exec(pg.ctx,
`INSERT INTO role_permissions (role, permission, granted)
VALUES ($1, $2, $3) ON CONFLICT (role, permission) DO UPDATE SET granted = EXCLUDED.granted`,
role, permission, granted)
return err
}
func (pg *PostgresDB) DeleteRolePermission(role, permission string) error {
_, err := pg.pool.Exec(pg.ctx,
`DELETE FROM role_permissions WHERE role = $1 AND permission = $2`, role, permission)
return err
}
func (pg *PostgresDB) HasRolePermission(role, permission string) (bool, error) {
var granted bool
err := pg.pool.QueryRow(pg.ctx,
`SELECT granted FROM role_permissions WHERE role = $1 AND permission = $2`,
role, permission).Scan(&granted)
if err == pgx.ErrNoRows {
return false, fmt.Errorf("no override")
}
return granted, err
}
func (pg *PostgresDB) ListPeersForOrg(orgID string, includeDeleted bool) ([]*Peer, error) {
query := `SELECT ` + peerColumns + ` FROM peers p
INNER JOIN org_devices od ON p.id = od.device_id
WHERE od.org_id = $1`
if !includeDeleted {
query += ` AND p.soft_deleted = FALSE`
}
query += ` ORDER BY p.last_online DESC NULLS LAST`
rows, err := pg.pool.Query(pg.ctx, query, orgID)
if err != nil {
return nil, fmt.Errorf("db: ListPeersForOrg: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p, err := scanPeer(rows)
if err != nil {
return nil, err
}
peers = append(peers, p)
}
return peers, rows.Err()
}
+144 -11
View File
@@ -253,6 +253,16 @@ func (s *SQLiteDB) Migrate() error {
updated_at TEXT DEFAULT '',
updated_by TEXT DEFAULT ''
)`,
// Role permissions (RBAC Phase 52) — custom permission overrides per role
`CREATE TABLE IF NOT EXISTS role_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
permission TEXT NOT NULL,
granted INTEGER NOT NULL DEFAULT 1,
UNIQUE(role, permission)
)`,
`CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role)`,
}
for _, stmt := range statements {
@@ -285,6 +295,8 @@ func (s *SQLiteDB) Migrate() error {
{"peers", "linked_peer_id", `ALTER TABLE peers ADD COLUMN linked_peer_id TEXT DEFAULT ''`},
// peers: display_name alias (added in v2.6.0)
{"peers", "display_name", `ALTER TABLE peers ADD COLUMN display_name TEXT DEFAULT ''`},
// users: is_server_admin flag (RBAC Phase 52)
{"users", "is_server_admin", `ALTER TABLE users ADD COLUMN is_server_admin INTEGER DEFAULT 0`},
}
for _, m := range columnMigrations {
@@ -938,9 +950,9 @@ func (s *SQLiteDB) GetUser(username string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
u := &User{}
err := s.db.QueryRow(`SELECT id, username, password_hash, role, totp_secret, totp_enabled,
created_at, last_login FROM users WHERE username = ?`, username).Scan(
&u.ID, &u.Username, &u.PasswordHash, &u.Role,
err := s.db.QueryRow(`SELECT id, username, password_hash, role, COALESCE(is_server_admin, 0),
totp_secret, totp_enabled, created_at, last_login FROM users WHERE username = ?`, username).Scan(
&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin,
&u.TOTPSecret, &u.TOTPEnabled, &u.CreatedAt, &u.LastLogin)
if err == sql.ErrNoRows {
return nil, nil
@@ -953,9 +965,9 @@ func (s *SQLiteDB) GetUserByID(id int64) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
u := &User{}
err := s.db.QueryRow(`SELECT id, username, password_hash, role, totp_secret, totp_enabled,
created_at, last_login FROM users WHERE id = ?`, id).Scan(
&u.ID, &u.Username, &u.PasswordHash, &u.Role,
err := s.db.QueryRow(`SELECT id, username, password_hash, role, COALESCE(is_server_admin, 0),
totp_secret, totp_enabled, created_at, last_login FROM users WHERE id = ?`, id).Scan(
&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin,
&u.TOTPSecret, &u.TOTPEnabled, &u.CreatedAt, &u.LastLogin)
if err == sql.ErrNoRows {
return nil, nil
@@ -967,8 +979,8 @@ func (s *SQLiteDB) GetUserByID(id int64) (*User, error) {
func (s *SQLiteDB) ListUsers() ([]*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT id, username, password_hash, role, totp_secret, totp_enabled,
created_at, last_login FROM users ORDER BY id`)
rows, err := s.db.Query(`SELECT id, username, password_hash, role, COALESCE(is_server_admin, 0),
totp_secret, totp_enabled, created_at, last_login FROM users ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("db: ListUsers: %w", err)
}
@@ -976,7 +988,7 @@ func (s *SQLiteDB) ListUsers() ([]*User, error) {
var users []*User
for rows.Next() {
u := &User{}
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.IsServerAdmin,
&u.TOTPSecret, &u.TOTPEnabled, &u.CreatedAt, &u.LastLogin); err != nil {
return nil, err
}
@@ -989,8 +1001,9 @@ func (s *SQLiteDB) ListUsers() ([]*User, error) {
func (s *SQLiteDB) UpdateUser(u *User) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`UPDATE users SET password_hash=?, role=?, totp_secret=?, totp_enabled=?
WHERE id=?`, u.PasswordHash, u.Role, u.TOTPSecret, u.TOTPEnabled, u.ID)
_, err := s.db.Exec(`UPDATE users SET password_hash=?, role=?, is_server_admin=?,
totp_secret=?, totp_enabled=? WHERE id=?`,
u.PasswordHash, u.Role, u.IsServerAdmin, u.TOTPSecret, u.TOTPEnabled, u.ID)
return err
}
@@ -1707,3 +1720,123 @@ func (s *SQLiteDB) DeleteAccessPolicy(peerID string) error {
_, err := s.db.Exec(`DELETE FROM access_policies WHERE peer_id = ?`, peerID)
return err
}
// --- Role Permissions (RBAC Phase 52) ---
// ListRolePermissions returns all custom permission overrides for a role.
func (s *SQLiteDB) ListRolePermissions(role string) ([]*RolePermission, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows, err := s.db.Query(`SELECT id, role, permission, granted FROM role_permissions WHERE role = ?`, role)
if err != nil {
return nil, fmt.Errorf("db: ListRolePermissions: %w", err)
}
defer rows.Close()
var perms []*RolePermission
for rows.Next() {
p := &RolePermission{}
if err := rows.Scan(&p.ID, &p.Role, &p.Permission, &p.Granted); err != nil {
return nil, err
}
perms = append(perms, p)
}
return perms, rows.Err()
}
// SetRolePermission creates or updates a custom permission override for a role.
func (s *SQLiteDB) SetRolePermission(role, permission string, granted bool) error {
s.mu.Lock()
defer s.mu.Unlock()
grantedInt := 0
if granted {
grantedInt = 1
}
_, err := s.db.Exec(`INSERT INTO role_permissions (role, permission, granted)
VALUES (?, ?, ?) ON CONFLICT(role, permission) DO UPDATE SET granted = excluded.granted`,
role, permission, grantedInt)
return err
}
// DeleteRolePermission removes a custom permission override, reverting to defaults.
func (s *SQLiteDB) DeleteRolePermission(role, permission string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`DELETE FROM role_permissions WHERE role = ? AND permission = ?`, role, permission)
return err
}
// HasRolePermission checks if a custom permission override exists for a role.
// Returns (granted_value, nil) if found, or (false, error) if no override exists.
func (s *SQLiteDB) HasRolePermission(role, permission string) (bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
var granted bool
err := s.db.QueryRow(`SELECT granted FROM role_permissions WHERE role = ? AND permission = ?`,
role, permission).Scan(&granted)
if err == sql.ErrNoRows {
return false, fmt.Errorf("no override")
}
return granted, err
}
// --- Org-scoped device queries (RBAC Phase 52) ---
// ListPeersForOrg returns only peers assigned to the given organization.
func (s *SQLiteDB) ListPeersForOrg(orgID string, includeDeleted bool) ([]*Peer, error) {
s.mu.RLock()
defer s.mu.RUnlock()
query := `SELECT p.id, p.uuid, p.pk, p.ip, p.user, p.hostname, p.os, p.version,
p.status, p.nat_type, p.last_online, p.created_at, p.disabled,
p.banned, p.ban_reason, p.banned_at, p.soft_deleted, p.deleted_at,
p.note, p.tags, p.heartbeat_seq, COALESCE(p.display_name, ''),
COALESCE(p.device_type, ''), COALESCE(p.linked_peer_id, '')
FROM peers p INNER JOIN org_devices od ON p.id = od.device_id
WHERE od.org_id = ?`
if !includeDeleted {
query += ` AND p.soft_deleted = 0`
}
query += ` ORDER BY p.last_online DESC`
rows, err := s.db.Query(query, orgID)
if err != nil {
return nil, fmt.Errorf("db: ListPeersForOrg: %w", err)
}
defer rows.Close()
var peers []*Peer
for rows.Next() {
p := &Peer{}
var lastOnline, createdAt, bannedAt, deletedAt sql.NullString
if err := rows.Scan(
&p.ID, &p.UUID, &p.PK, &p.IP, &p.User, &p.Hostname, &p.OS, &p.Version,
&p.Status, &p.NATType, &lastOnline, &createdAt, &p.Disabled,
&p.Banned, &p.BanReason, &bannedAt, &p.SoftDeleted, &deletedAt,
&p.Note, &p.Tags, &p.HeartbeatSeq, &p.DisplayName,
&p.DeviceType, &p.LinkedPeerID,
); err != nil {
return nil, err
}
if lastOnline.Valid {
p.LastOnline, _ = time.Parse("2006-01-02 15:04:05", lastOnline.String)
}
if createdAt.Valid {
p.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt.String)
}
if bannedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", bannedAt.String)
p.BannedAt = &t
}
if deletedAt.Valid {
t, _ := time.Parse("2006-01-02 15:04:05", deletedAt.String)
p.DeletedAt = &t
}
peers = append(peers, p)
}
return peers, rows.Err()
}
+5 -5
View File
@@ -479,17 +479,17 @@ func loadAPIKey(cfg *config.Config, database db.Database) {
log.Printf("API key loaded from %s (already in database)", source)
} else {
if err := database.SetConfig("api_key", apiKey); err != nil {
log.Printf("WARN: Failed to sync API key to database: %v", err)
return
}
if existing == "" {
log.Printf("WARN: Failed to sync API key to server_config: %v", err)
// Do NOT return here — ensureScopedAPIKey below is critical for auth
} else if existing == "" {
log.Printf("API key loaded from %s and stored in database", source)
} else {
log.Printf("API key loaded from %s and updated in database", source)
}
}
// Always ensure the scoped API key exists in api_keys table
// Always ensure the scoped API key exists in api_keys table.
// This is critical — authenticateRequest() checks ONLY the api_keys table.
if err := ensureScopedAPIKey(database, apiKey); err != nil {
log.Printf("WARN: Failed to migrate API key into scoped api_keys table: %v", err)
} else {
+8
View File
@@ -5,11 +5,13 @@ package relay
import (
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
@@ -124,6 +126,12 @@ func (s *Server) serveTCP() {
case <-s.ctx.Done():
return
default:
// Filter noisy but harmless accept errors (scanners, TLS probes, resets)
if errors.Is(err, io.EOF) ||
strings.Contains(err.Error(), "connection reset") ||
strings.Contains(err.Error(), "use of closed") {
continue
}
log.Printf("[relay] TCP accept error: %v", err)
continue
}
+42 -45
View File
@@ -632,24 +632,22 @@ func (s *Server) handlePunchHoleRequestTCP(msg *pb.PunchHoleRequest, raddr *net.
}
}
// Forward PunchHole to the TARGET peer via UDP (tell it the initiator wants to connect).
if target.UDPAddr != nil {
punchHole := &pb.RendezvousMessage{
Union: &pb.RendezvousMessage_PunchHole{
PunchHole: &pb.PunchHole{
SocketAddr: crypto.EncodeAddr(raddr),
RelayServer: relayServer,
NatType: msg.NatType,
UdpPort: msg.UdpPort,
ForceRelay: msg.ForceRelay,
UpnpPort: msg.UpnpPort,
SocketAddrV6: msg.SocketAddrV6,
},
// Forward PunchHole to the TARGET peer (supports UDP, TCP, and WebSocket targets).
punchHole := &pb.RendezvousMessage{
Union: &pb.RendezvousMessage_PunchHole{
PunchHole: &pb.PunchHole{
SocketAddr: crypto.EncodeAddr(raddr),
RelayServer: relayServer,
NatType: msg.NatType,
UdpPort: msg.UdpPort,
ForceRelay: msg.ForceRelay,
UpnpPort: msg.UpnpPort,
SocketAddrV6: msg.SocketAddrV6,
},
}
s.sendUDP(punchHole, target.UDPAddr)
log.Printf("[signal] PunchHole (TCP): forwarded PunchHole to target %s at %s", targetID, target.UDPAddr)
},
}
s.sendToPeer(targetID, punchHole)
log.Printf("[signal] PunchHole (TCP): forwarded to target %s (connType=%s)", targetID, target.ConnType)
// LAN detection: if both peers share the same public IP or are on the same
// private /24 subnet, they are on the same local network.
@@ -870,23 +868,23 @@ func (s *Server) handleRequestRelay(msg *pb.RequestRelay, raddr *net.UDPAddr) {
log.Printf("[signal] RequestRelay LAN detected: %s and %s on same network, relay=%s", raddr.IP, target.UDPAddr.IP, relayServer)
}
// Forward relay info to target peer
relayResp := &pb.RendezvousMessage{
Union: &pb.RendezvousMessage_RelayResponse{
RelayResponse: &pb.RelayResponse{
// Forward relay request to target peer (supports UDP, TCP, and WebSocket targets).
// NOTE: Must use RequestRelay type, not RelayResponse — RustDesk client's
// handle_resp() dispatches RequestRelay to create_relay() but drops RelayResponse.
relayReq := &pb.RendezvousMessage{
Union: &pb.RendezvousMessage_RequestRelay{
RequestRelay: &pb.RequestRelay{
SocketAddr: crypto.EncodeAddr(raddr),
Uuid: relayUUID,
Id: msg.Id,
RelayServer: relayServer,
Union: &pb.RelayResponse_Id{Id: msg.Id},
},
},
}
if target.UDPAddr != nil {
// Store the UUID so we can recover it if target responds with empty UUID.
s.storePendingUUID(targetID, relayUUID)
s.sendUDP(relayResp, target.UDPAddr)
}
// Store the UUID so we can recover it if target responds with empty UUID.
s.storePendingUUID(targetID, relayUUID)
s.sendToPeer(targetID, relayReq)
// Sign the target's PK for E2E encryption verification
var signedPk []byte
@@ -967,32 +965,31 @@ func (s *Server) handleRequestRelayTCP(msg *pb.RequestRelay, raddr *net.UDPAddr)
}
// LAN detection: use server's LAN IP for relay when both peers are on same network.
// Only applicable when target has a known UDP address for comparison.
if target.UDPAddr != nil && isSameNetwork(raddr, target.UDPAddr) {
relayServer = s.getLANRelayServer()
log.Printf("[signal] RequestRelay (TCP) LAN detected: %s and %s on same network, relay=%s", raddr.IP, target.UDPAddr.IP, relayServer)
}
// Forward RequestRelay to target peer via UDP.
if target.UDPAddr != nil {
reqRelay := &pb.RendezvousMessage{
Union: &pb.RendezvousMessage_RequestRelay{
RequestRelay: &pb.RequestRelay{
SocketAddr: crypto.EncodeAddr(raddr),
Uuid: relayUUID,
Id: msg.Id,
RelayServer: relayServer,
Secure: msg.Secure,
ConnType: msg.ConnType,
Token: msg.Token,
ControlPermissions: msg.ControlPermissions,
},
// Forward RequestRelay to target peer (supports UDP, TCP, and WebSocket targets).
reqRelay := &pb.RendezvousMessage{
Union: &pb.RendezvousMessage_RequestRelay{
RequestRelay: &pb.RequestRelay{
SocketAddr: crypto.EncodeAddr(raddr),
Uuid: relayUUID,
Id: msg.Id,
RelayServer: relayServer,
Secure: msg.Secure,
ConnType: msg.ConnType,
Token: msg.Token,
ControlPermissions: msg.ControlPermissions,
},
}
// Store the UUID so we can recover it if target responds with empty UUID.
s.storePendingUUID(targetID, relayUUID)
s.sendUDP(reqRelay, target.UDPAddr)
log.Printf("[signal] RequestRelay (TCP): forwarded to %s secure=%v connType=%v", targetID, msg.Secure, msg.ConnType)
},
}
// Store the UUID so we can recover it if target responds with empty UUID.
s.storePendingUUID(targetID, relayUUID)
s.sendToPeer(targetID, reqRelay)
log.Printf("[signal] RequestRelay (TCP): forwarded to %s (connType=%s) secure=%v", targetID, target.ConnType, msg.Secure)
// Sign the target's PK for E2E encryption verification
var signedPk []byte
+7
View File
@@ -5,6 +5,7 @@ package signal
import (
"context"
"errors"
"fmt"
"io"
"log"
@@ -364,6 +365,12 @@ func (s *Server) serveTCP() {
case <-s.ctx.Done():
return
default:
// Filter noisy but harmless accept errors (scanners, TLS probes, resets)
if errors.Is(err, io.EOF) ||
strings.Contains(err.Error(), "connection reset") ||
strings.Contains(err.Error(), "use of closed") {
continue
}
log.Printf("[signal] TCP accept error: %v", err)
continue
}
+165
View File
@@ -0,0 +1,165 @@
# RBAC Phase 52 — Granular Permissions, 6-Role Hierarchy & Data Scoping
> Implemented: 2026-04-10 | Updated: 2026-04-10 (6-role hierarchy) | Discussion: #99
## Overview
Phase 52 implements the full RBAC overhaul proposed in Discussion #99:
- **Phase 1**: JWT org context + org-scoped data filtering
- **Phase 2**: 28 granular permissions replacing role-level gates
- **Phase 3**: Super admin protection (self-demotion, role boundary, server admin)
- **Phase 4**: 6-role hierarchy (super_admin, server_admin, global_admin + legacy admin/operator/viewer/pro)
- **Phase 5**: Org role boundary enforcement + privilege escalation prevention
- **Phase 6**: Peer org-scoping on single-device endpoints
## 6-Role Hierarchy (Branched)
```
super_admin ← Full access to everything, manages other super admins
├── server_admin ← Server infrastructure only, read-only user visibility
├── global_admin ← All-org user/device management, NO server access
└── admin ← Legacy alias, equivalent to super_admin
├── operator ← Day-to-day device ops + chat
├── viewer ← Read-only dashboards
└── pro ← API-only device view
```
**Key design**: `server_admin` and `global_admin` are parallel roles — not one above the other.
They share the same privilege level (4) but have DIFFERENT permission sets.
Use `auth.CanAssignRole()` for role assignment boundaries instead of RoleLevel comparison.
## Changes
### New Files
| File | Description |
|------|-------------|
| `auth/permissions.go` | 28 permission constants, 7 default role maps, helpers |
### Modified Files
| File | Changes |
|------|---------|
| `auth/roles.go` | 7 role constants (super_admin, server_admin, global_admin, admin, operator, viewer, pro), branched `RoleLevel()`, `IsSuperAdminRole()`, `IsServerLevel()`, `CanAssignRole()` |
| `auth/jwt.go` | `OrgID` field in Claims, `GenerateOrgToken()` method |
| `db/database.go` | `User.IsServerAdmin`, `RolePermission` struct, `OrgRoleLevel()`, `OrgCanAssignRole()`, `ValidOrgRole()`, 5 new interface methods |
| `db/sqlite.go` | `role_permissions` table, `is_server_admin` column, 5 method implementations |
| `db/postgres.go` | Same as sqlite — table, column, 5 method implementations |
| `api/auth_handlers.go` | `requirePermission()` with super_admin bypass, `requireOrgMembership()` with global_admin bypass, `peerOrgScopeCheck()`, `CanAssignRole()` in create/update user, last-admin demotion guard |
| `api/server.go` | ~30 routes migrated to `requirePermission`, peer org scope checks on 7 single-device endpoints |
| `api/org_handlers.go` | Org login embeds `org_id` in JWT, org role boundary in create/update user, org user visibility scoping |
| `web-nodejs/middleware/auth.js` | 7 role permission maps, `isSuperAdminRole()`, updated `requireAdmin()` + `requireRole()` + `requirePermission()` |
## Permission System
### 28 Granular Permissions
| Category | Permissions |
|----------|------------|
| Device | `device.view`, `device.connect`, `device.edit`, `device.delete`, `device.ban`, `device.change_id` |
| User | `user.view`, `user.create`, `user.edit`, `user.delete` |
| Server | `server.config`, `server.keys` |
| Organization | `org.create`, `org.edit`, `org.delete`, `org.manage_users`, `org.manage_devices` |
| Audit | `audit.view`, `metrics.view`, `blocklist.edit` |
| CDAP | `cdap.view`, `cdap.command`, `cdap.terminal`, `cdap.files` |
| Enrollment | `enrollment.manage`, `enrollment.approve` |
| Other | `chat.access`, `branding.edit` |
### Default Role Mappings
| Role | # Permissions | Key Permissions |
|------|--------------|----------------|
| `super_admin` | All 28 | Everything |
| `admin` | All 28 | Legacy alias for super_admin |
| `server_admin` | 8 | server.config, server.keys, blocklist.edit, user.view (read-only!), device.view, audit.view, metrics.view, enrollment.manage |
| `global_admin` | 22 | user.*, org.*, device.*, audit.view, metrics.view, cdap.view/.command, chat.access, enrollment.*, branding.edit — **NO** server.config/server.keys |
| `operator` | 12 | device.view/.connect/.edit, user.view, audit.view, metrics.view, cdap.view/.command, enrollment.approve, chat.access, org.manage_devices |
| `viewer` | 5 | device.view, audit.view, metrics.view, cdap.view, chat.access |
| `pro` | 1 | device.view |
### Role Assignment Boundaries
| Caller | Can Assign |
|--------|-----------|
| `super_admin` / `admin` | Any role |
| `global_admin` | `operator`, `viewer`, `pro` only |
| `server_admin` | Cannot assign any roles |
| `operator` / `viewer` / `pro` | Cannot assign any roles |
### Org Role Boundaries
| Caller Org Role | Can Assign |
|----------------|-----------|
| `owner` | `admin`, `operator`, `user` (not another `owner`) |
| `admin` | `operator`, `user` |
| `operator` / `user` | Cannot assign any roles |
### Custom Permission Overrides
The `role_permissions` table allows overriding defaults:
```sql
-- Grant operators the ability to delete devices
INSERT INTO role_permissions (role, permission, granted) VALUES ('operator', 'device.delete', true);
-- Revoke chat from viewers
INSERT INTO role_permissions (role, permission, granted) VALUES ('viewer', 'chat.access', false);
```
The `requirePermission` middleware checks DB overrides first, then falls back to defaults.
## Security Protections
| Protection | Description |
|-----------|-------------|
| Self-demotion prevention | Admins cannot lower their own role |
| Role boundary enforcement | `CanAssignRole()` enforces branched hierarchy — server_admin can't assign, global_admin only below GA |
| Server admin flag | `is_server_admin` field — only server admins can modify other server admins |
| Last-admin deletion guard | Cannot delete the sole remaining admin |
| Last-admin demotion guard | Cannot demote the last super_admin/admin (409 Conflict) |
| Org role boundary | `OrgCanAssignRole()` enforces org-level hierarchy (owner → admin/op/user, admin → op/user) |
| Org self-modification block | Cannot change own org role |
| Org authority check | Cannot modify users at or above own org-level authority |
| Peer org scoping | Org-scoped users can only access devices assigned to their org |
| Org user visibility | Org users can only see themselves in the user list |
## Data Scoping
| Endpoint | Scoping |
|----------|---------|
| `GET /api/peers` | Org-scoped users see only their org's devices via `ListPeersForOrg()` |
| `GET /api/peers/{id}` | `peerOrgScopeCheck()` — verifies peer belongs to caller's org |
| `DELETE /api/peers/{id}` | Same peer org scope check |
| `PATCH /api/peers/{id}` | Same peer org scope check |
| `POST /api/peers/{id}/ban` | Same peer org scope check |
| `POST /api/peers/{id}/unban` | Same peer org scope check |
| `POST /api/peers/{id}/change-id` | Same peer org scope check |
| `GET /api/peers/{id}/metrics` | Same peer org scope check |
| `GET /api/org/{id}/users` | Org users only see themselves; org admin/operator see all |
| `GET /api/orgs` | Non-admin users only see orgs they belong to |
## Database Schema
### New Table: `role_permissions`
```sql
CREATE TABLE role_permissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
permission TEXT NOT NULL,
granted INTEGER NOT NULL DEFAULT 1,
UNIQUE(role, permission)
);
```
### New Column: `users.is_server_admin`
```sql
ALTER TABLE users ADD COLUMN is_server_admin INTEGER DEFAULT 0;
```
## API Response Changes
- `GET /api/auth/me` — now includes `is_server_admin` field
- `GET /api/users` — user list now includes `is_server_admin` field
- Error responses include specific permission names (e.g., `"Permission denied: device.delete"`)
+10
View File
@@ -68,6 +68,16 @@ function resolveKeysPath() {
const KEYS_PATH = resolveKeysPath();
const RUSTDESK_DIR = KEYS_PATH;
// Warn if KEYS_PATH was auto-detected and looks wrong
if (!process.env.KEYS_PATH && !process.env.RUSTDESK_DIR && !process.env.RUSTDESK_PATH) {
const apiKeyFile = path.join(KEYS_PATH, '.api_key');
const keyFile = path.join(KEYS_PATH, 'id_ed25519');
if (!fs.existsSync(apiKeyFile) && !fs.existsSync(keyFile)) {
console.warn(`⚠️ KEYS_PATH auto-detected as "${KEYS_PATH}" but no .api_key or id_ed25519 found there.`);
console.warn(' Set KEYS_PATH in .env to point to the Go server data directory.');
}
}
// Database path
const DB_PATH = process.env.DB_PATH || path.join(RUSTDESK_DIR, 'db_v2.sqlite3');
+5 -1
View File
@@ -638,7 +638,11 @@
"file_rename": "Rename",
"file_rename_prompt": "Enter new name:",
"file_delete_confirm": "Are you sure you want to delete this item?",
"file_cancelled": "Transfer cancelled"
"file_cancelled": "Transfer cancelled",
"2fa_required": "2FA Verification Required",
"2fa_hint": "Enter the 6-digit code from your authenticator app",
"2fa_invalid": "Enter a valid 6-digit code",
"verify": "Verify"
},
"branding": {
"identity_title": "Brand Identity",
+5 -1
View File
@@ -638,7 +638,11 @@
"file_rename": "Zmień nazwę",
"file_rename_prompt": "Podaj nową nazwę:",
"file_delete_confirm": "Czy na pewno chcesz usunąć ten element?",
"file_cancelled": "Transfer anulowany"
"file_cancelled": "Transfer anulowany",
"2fa_required": "Wymagana weryfikacja 2FA",
"2fa_hint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej",
"2fa_invalid": "Wprowadź prawidłowy 6-cyfrowy kod",
"verify": "Weryfikuj"
},
"branding": {
"identity_title": "Identyfikacja marki",
+5 -1
View File
@@ -638,7 +638,11 @@
"file_rename": "重命名",
"file_rename_prompt": "输入新名称:",
"file_delete_confirm": "确定要删除此项吗?",
"file_cancelled": "传输已取消"
"file_cancelled": "传输已取消",
"2fa_required": "需要双因素验证",
"2fa_hint": "输入验证器应用中的6位数代码",
"2fa_invalid": "请输入有效的6位数代码",
"verify": "验证"
},
"branding": {
"identity_title": "品牌标识",
+124 -4
View File
@@ -3,6 +3,84 @@
* Protects routes that require authentication
*/
// Default role-permission map (mirrors Go auth/permissions.go — Phase 52+)
//
// Role hierarchy (branched, not strictly linear):
// super_admin — full server + all-org access
// ├── server_admin — server config/logs, read-only user visibility
// ├── global_admin — all-org user/device management, no server access
// └── admin — legacy alias for super_admin
// operator, viewer, pro — unchanged
const DEFAULT_ROLE_PERMISSIONS = {
super_admin: null, // null = ALL permissions
admin: null, // legacy admin = super_admin
// Server Admin: infrastructure + read-only users
server_admin: new Set([
'server.config', 'server.keys',
'blocklist.edit',
'user.view',
'device.view',
'audit.view', 'metrics.view',
'enrollment.manage',
]),
// Global Admin: all user/org management, NO server config
global_admin: new Set([
'user.view', 'user.create', 'user.edit', 'user.delete',
'org.create', 'org.edit', 'org.delete', 'org.manage_users', 'org.manage_devices',
'device.view', 'device.connect', 'device.edit', 'device.delete',
'device.ban', 'device.change_id',
'audit.view', 'metrics.view',
'cdap.view', 'cdap.command',
'chat.access',
'enrollment.manage', 'enrollment.approve',
'branding.edit',
]),
operator: new Set([
'device.view', 'device.connect', 'device.edit',
'user.view',
'audit.view', 'metrics.view',
'cdap.view', 'cdap.command',
'enrollment.approve',
'chat.access',
'org.manage_devices',
]),
viewer: new Set([
'device.view',
'audit.view', 'metrics.view',
'cdap.view',
'chat.access',
]),
pro: new Set([
'device.view',
]),
};
// Roles that have full admin privileges (bypass all permission checks).
const SUPER_ADMIN_ROLES = new Set(['super_admin', 'admin']);
/**
* Check if a role is a super admin (or legacy admin).
*/
function isSuperAdminRole(role) {
return SUPER_ADMIN_ROLES.has(role);
}
/**
* Check if a role has a specific permission by default.
* @param {string} role
* @param {string} permission
* @returns {boolean}
*/
function roleHasPermission(role, permission) {
if (isSuperAdminRole(role)) return true;
const perms = DEFAULT_ROLE_PERMISSIONS[role];
if (!perms) return false;
return perms.has(permission);
}
/**
* Require authentication middleware
* Redirects to login page for HTML requests, returns 401 for API requests
@@ -51,7 +129,15 @@ function requireRole(role) {
return res.redirect('/login');
}
if (req.session.user.role !== role && req.session.user.role !== 'admin') {
const userRole = req.session.user && req.session.user.role;
// Super admin roles bypass all role checks.
if (isSuperAdminRole(userRole)) {
res.locals.user = req.session.user;
return next();
}
if (userRole !== role) {
if (req.path.startsWith('/api/')) {
return res.status(403).json({ success: false, error: 'Forbidden' });
}
@@ -89,7 +175,7 @@ function guestOnly(req, res, next) {
}
/**
* Require admin role
* Require admin role (super_admin, admin, or global_admin for user management)
*/
function requireAdmin(req, res, next) {
if (!req.session || !req.session.userId) {
@@ -99,7 +185,8 @@ function requireAdmin(req, res, next) {
return res.redirect('/login');
}
if (req.session.user.role !== 'admin') {
const userRole = req.session.user && req.session.user.role;
if (!isSuperAdminRole(userRole) && userRole !== 'global_admin') {
if (req.path.startsWith('/api/')) {
return res.status(403).json({ success: false, error: 'Admin access required' });
}
@@ -113,10 +200,43 @@ function requireAdmin(req, res, next) {
next();
}
/**
* Require a specific granular permission (RBAC Phase 52).
* Uses the default role-permission map. Admin role always passes.
* @param {string} permission - e.g. 'device.view', 'user.edit'
*/
function requirePermission(permission) {
return function(req, res, next) {
if (!req.session || !req.session.userId) {
if (req.path.startsWith('/api/')) {
return res.status(401).json({ success: false, error: 'Unauthorized' });
}
return res.redirect('/login');
}
const role = req.session.user && req.session.user.role;
if (!roleHasPermission(role, permission)) {
if (req.path.startsWith('/api/')) {
return res.status(403).json({ success: false, error: `Permission denied: ${permission}` });
}
return res.status(403).render('errors/403', {
title: 'Forbidden',
message: 'You do not have permission to access this resource'
});
}
res.locals.user = req.session.user;
next();
};
}
module.exports = {
requireAuth,
requireRole,
requireAdmin,
requirePermission,
optionalAuth,
guestOnly
guestOnly,
roleHasPermission,
isSuperAdminRole
};
+1
View File
@@ -891,6 +891,7 @@
border: 1px solid var(--studio-panel-border);
border-radius: 8px;
cursor: pointer;
transform: translateY(0);
transition: border-color 0.15s, transform 0.15s;
}
+1
View File
@@ -140,6 +140,7 @@
border-radius: var(--radius-md, 8px);
padding: 1rem 1.15rem;
cursor: pointer;
transform: translateY(0);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
}
+1
View File
@@ -1156,6 +1156,7 @@ body.embed-mode .main-content {
background: rgba(255, 255, 255, 0.04);
padding: 10px;
cursor: pointer;
transform: translateY(0);
transition: background 0.15s ease, border-color 0.15s ease, transform 0.12s ease;
color: inherit;
}
+4 -1
View File
@@ -48,6 +48,7 @@ body.desktop-active .tutorial-help-menu {
0 0 0 1px rgba(255, 255, 255, 0.06) inset;
color: #e6edf3;
overflow: hidden;
transform: translateY(0);
transition: box-shadow 0.3s ease, border-color 0.3s ease, transform 0.2s ease;
contain: layout style;
will-change: transform;
@@ -993,7 +994,8 @@ body.desktop-active .tutorial-help-menu {
border-radius: 10px;
color: #e6edf3;
cursor: pointer;
transition: all 0.2s ease;
transform: translateY(0);
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease, color 0.2s ease;
font-size: 10px;
text-align: center;
}
@@ -2528,6 +2530,7 @@ body.desktop-active .tutorial-help-menu {
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
transform: translateY(0);
transition: background 0.15s, border-color 0.15s, transform 0.1s;
text-align: center;
}
+1
View File
@@ -14,6 +14,7 @@
border-radius: 8px;
padding: 20px;
cursor: pointer;
transform: translateY(0);
transition: border-color 0.2s, transform 0.15s;
text-align: center;
}
+34 -15
View File
@@ -76,14 +76,20 @@ const DeviceDetail = (function () {
overlayEl.className = 'device-panel-overlay';
overlayEl.innerHTML = '<div class="device-panel" id="device-panel-inner"></div>';
// Close on overlay click
// Close on overlay click — skip if a modal dialog is open on top
overlayEl.addEventListener('click', function (e) {
if (e.target === overlayEl) close();
if (e.target === overlayEl) {
if (document.querySelector('.modal-overlay.open, .modal-container.open')) return;
close();
}
});
// Escape key
// Escape key — skip if a modal dialog is open on top
overlayEl._escHandler = function (e) {
if (e.key === 'Escape') close();
if (e.key === 'Escape') {
if (document.querySelector('.modal-overlay.open, .modal-container.open')) return;
close();
}
};
document.addEventListener('keydown', overlayEl._escHandler);
@@ -836,6 +842,10 @@ const DeviceDetail = (function () {
}
async function _changeId() {
// Capture device ID before async operation — panel close sets device=null
const deviceId = device && device.id;
if (!deviceId) return;
const newId = await Modal.prompt({
title: _('devices.change_id_title'),
label: _('devices.new_id'),
@@ -852,7 +862,7 @@ const DeviceDetail = (function () {
return;
}
try {
await Utils.api('/api/devices/' + encodeURIComponent(device.id) + '/change-id', {
await Utils.api('/api/devices/' + encodeURIComponent(deviceId) + '/change-id', {
method: 'POST',
body: { newId: newId.toUpperCase() }
});
@@ -865,23 +875,28 @@ const DeviceDetail = (function () {
}
async function _toggleBan() {
const isBanned = device.banned;
// Capture before async — panel close sets device=null
const deviceId = device && device.id;
const isBanned = device && device.banned;
if (!deviceId) return;
const action = isBanned ? 'unban' : 'ban';
const confirmed = await Modal.confirm({
title: _('devices.' + action + '_title'),
message: _('devices.' + action + '_confirm', { id: device.id }),
message: _('devices.' + action + '_confirm', { id: deviceId }),
confirmLabel: _(isBanned ? 'actions.unban' : 'actions.ban'),
danger: !isBanned
});
if (!confirmed) return;
try {
await Utils.api('/api/devices/' + encodeURIComponent(device.id) + '/' + action, { method: 'POST' });
await Utils.api('/api/devices/' + encodeURIComponent(deviceId) + '/' + action, { method: 'POST' });
Notifications.success(_('devices.' + action + '_success'));
// Refresh panel
const id = device.id;
device = await Utils.api('/api/devices/' + encodeURIComponent(id));
_render();
_switchTab('actions');
// Refresh panel if still open
if (overlayEl) {
device = await Utils.api('/api/devices/' + encodeURIComponent(deviceId));
_render();
_switchTab('actions');
}
_notifyChanged();
} catch (err) {
Notifications.error(err.message || _('errors.' + action + '_failed'));
@@ -889,16 +904,20 @@ const DeviceDetail = (function () {
}
async function _deleteDevice() {
// Capture before async — panel close sets device=null
const deviceId = device && device.id;
if (!deviceId) return;
const confirmed = await Modal.confirm({
title: _('devices.delete_title'),
message: _('devices.delete_confirm', { id: device.id }),
message: _('devices.delete_confirm', { id: deviceId }),
confirmLabel: _('actions.delete'),
confirmIcon: 'delete',
danger: true
});
if (!confirmed) return;
try {
await Utils.api('/api/devices/' + encodeURIComponent(device.id), { method: 'DELETE' });
await Utils.api('/api/devices/' + encodeURIComponent(deviceId), { method: 'DELETE' });
Notifications.success(_('devices.delete_success'));
close();
_notifyChanged();
+3
View File
@@ -194,4 +194,7 @@
// Public API
window.Languages = { init, viewMissing, fixMissing, closeDetail };
// Self-initialize (inline <script> blocked by CSP nonce policy)
document.addEventListener('DOMContentLoaded', init);
})();
+32 -1
View File
@@ -47,7 +47,7 @@ class RDClient {
});
// State
this._state = 'idle'; // idle | connecting | waiting_password | authenticating | streaming | disconnected | error
this._state = 'idle'; // idle | connecting | waiting_password | waiting_2fa | authenticating | streaming | disconnected | error
this._listeners = {};
this._peerInfo = null;
this._loginChallenge = null;
@@ -269,6 +269,27 @@ class RDClient {
}
}
/**
* Submit 2FA verification code (TOTP)
* @param {string} code - 6-digit TOTP code
*/
submit2FA(code) {
try {
this._setState('authenticating');
this._emit('log', 'Verifying 2FA code...');
const auth2fa = {
auth2Fa: {
code: code.trim()
}
};
this._sendPeerMessage(auth2fa);
console.log('[RDClient] Auth2FA sent');
} catch (err) {
this._handleError(err);
}
}
/**
* Disconnect from remote device
*/
@@ -835,7 +856,17 @@ class RDClient {
}).substring(0, 500));
if (resp.error && resp.error.length > 0) {
const errLower = resp.error.toLowerCase();
console.log('[RDClient] Login error: ' + resp.error);
// Detect 2FA requirement from error message
if (errLower.includes('2fa') || errLower.includes('totp') || errLower.includes('verification code')) {
console.log('[RDClient] 2FA required by peer');
this._setState('waiting_2fa');
this._emit('2fa_required');
return;
}
this._emit('login_error', resp.error);
this._setState('waiting_password');
return;
@@ -56,6 +56,7 @@ class RDProtocol {
this.types.ChatMessage = this.protoRoot.lookupType('hbb.ChatMessage');
this.types.TogglePrivacyMode = this.protoRoot.lookupType('hbb.TogglePrivacyMode');
this.types.SwitchDisplay = this.protoRoot.lookupType('hbb.SwitchDisplay');
this.types.Auth2FA = this.protoRoot.lookupType('hbb.Auth2FA');
// File transfer types
this.types.FileAction = this.protoRoot.lookupType('hbb.FileAction');
+38
View File
@@ -37,6 +37,9 @@
this.fileTransfersPanel = panel.querySelector('.session-file-transfers');
this.fileTransfersList = panel.querySelector('.session-file-transfers-list');
this.fileUploadInput = panel.querySelector('.session-file-upload-input');
this.tfaOverlay = panel.querySelector('.session-2fa-overlay');
this.tfaInput = panel.querySelector('.session-2fa-input');
this.tfaError = panel.querySelector('.session-2fa-error');
this.client = null;
this.state = 'idle';
this.latency = 0;
@@ -332,8 +335,18 @@
if (isActive(session)) session.passwordInput.focus();
});
c.on('2fa_required', () => {
session.passwordOverlay.style.display = 'none';
session.connectionOverlay.style.display = 'none';
session.tfaOverlay.style.display = 'flex';
session.tfaError.style.display = 'none';
session.tfaInput.value = '';
if (isActive(session)) session.tfaInput.focus();
});
c.on('login_success', () => {
session.passwordOverlay.style.display = 'none';
session.tfaOverlay.style.display = 'none';
session.passwordInput.blur();
});
@@ -407,6 +420,31 @@
}
});
// 2FA verification
session.panel.querySelector('.session-btn-verify-2fa')
?.addEventListener('click', () => {
const code = session.tfaInput.value.replace(/\s/g, '');
if (!code || code.length !== 6) {
session.tfaError.textContent = _('remote.2fa_invalid') || 'Enter a valid 6-digit code';
session.tfaError.style.display = 'block';
session.tfaInput.focus();
return;
}
session.tfaError.style.display = 'none';
if (session.client) session.client.submit2FA(code);
});
session.tfaInput?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
session.panel.querySelector('.session-btn-verify-2fa')?.click();
}
});
// Only allow digits in 2FA input
session.tfaInput?.addEventListener('input', () => {
session.tfaInput.value = session.tfaInput.value.replace(/[^0-9]/g, '');
});
session.panel.querySelector('.session-btn-chat-close')
?.addEventListener('click', () => {
session.chatPanel.style.display = 'none';
+10
View File
@@ -216,6 +216,16 @@ router.post('/register', identifyDevice, async (req, res) => {
return res.status(400).json({ error: 'device_id is required' });
}
// Reject registration with a stale/renamed peer ID (Issue #97)
const newId = db.getRenamedPeerId ? await db.getRenamedPeerId(id) : null;
if (newId) {
return res.status(409).json({
error: 'Device ID has been changed',
new_id: newId,
message: `This device ID was renamed to ${newId}. Please update your configuration.`,
});
}
// Build info JSON
const info = JSON.stringify({
hostname: hostname || '',
+11 -6
View File
@@ -566,7 +566,8 @@ function startRustDeskApiServer() {
*/
function printStartupBanner(protocol, port) {
const sslStatus = config.httpsEnabled ? '🔒 HTTPS' : '🔓 HTTP';
const apiStatus = config.apiEnabled ? `✅ Port ${config.apiPort}` : '❌ Disabled';
const apiStatus = config.apiEnabled ? `✅ Port ${config.apiPort} (HTTP)` : '❌ Disabled';
const panelUrl = `${protocol}://${config.host}:${port}`;
console.log('');
console.log(' ╔══════════════════════════════════════════════════╗');
console.log(' ║ ║');
@@ -574,15 +575,19 @@ function printStartupBanner(protocol, port) {
console.log(' ║ ║');
console.log(' ╠══════════════════════════════════════════════════╣');
console.log(' ║ ║');
console.log(` ║ Panel: ${protocol}://${config.host}:${port}`.padEnd(53) + '║');
console.log(` ║ Panel: ${panelUrl}`.padEnd(53) + '║');
if (config.httpsEnabled && config.httpRedirect) {
console.log(` ║ Redirect: http://${config.host}:${config.port} → :${config.httpsPort}`.padEnd(53) + '║');
}
console.log(` ║ Client API: ${apiStatus}`.padEnd(53) + '║');
console.log(`Mode: ${config.nodeEnv}`.padEnd(53) + '║');
console.log(`Security: ${sslStatus}`.padEnd(53) + '║');
console.log(`Go API: http://localhost:21114/api`.padEnd(53) + '║');
console.log(`Mode: ${config.nodeEnv}`.padEnd(53) + '║');
console.log(` ║ Security: ${sslStatus}`.padEnd(53) + '║');
const dbLabel = (db.DB_TYPE === 'postgres' || db.DB_TYPE === 'postgresql')
? `PostgreSQL (${process.env.DATABASE_URL ? new URL(process.env.DATABASE_URL).hostname : 'localhost'})`
: path.basename(config.dbPath);
console.log(` ║ Database: ${dbLabel}`.padEnd(53) + '║');
console.log(` ║ Keys: ${config.keysPath}`.padEnd(53) + '║');
console.log(` ║ Database: ${dbLabel}`.padEnd(53) + '║');
console.log(` ║ Keys: ${config.keysPath}`.padEnd(53) + '║');
console.log(' ║ ║');
console.log(' ╚══════════════════════════════════════════════════╝');
console.log('');
+124 -6
View File
@@ -17,6 +17,9 @@ const SALT_ROUNDS = 12;
// Pre-computed dummy hash for timing-safe comparison (prevents user enumeration)
const DUMMY_HASH = '$2b$12$KiXeOj5vHpJRJHGMhWzadeKfRJLvJRaRHQbMGBBdkpu.jQfXAzgWS';
const http = require('http');
const https = require('https');
// PBKDF2 parameters matching Go server's auth.HashPassword()
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_KEY_LENGTH = 32; // SHA-256 output size
@@ -74,6 +77,65 @@ async function verifyPassword(password, hash) {
return result.valid;
}
/**
* Fallback authentication against Go server's /api/auth/login endpoint.
* Used when local (Node.js) auth fails the Go server may have a different
* password hash (e.g., after fresh install race condition, or manual password
* change on Go server side).
* Returns { role: string } on success, or null on failure.
*/
function tryGoServerAuth(username, password) {
const apiUrl = config.betterdeskApiUrl || config.hbbsApiUrl || 'http://localhost:21114/api';
let authUrl;
try {
const base = new URL(apiUrl);
authUrl = new URL('/api/auth/login', base.origin);
} catch (_) {
return Promise.resolve(null);
}
const body = JSON.stringify({ username, password });
const mod = authUrl.protocol === 'https:' ? https : http;
const timeout = config.betterdeskApiTimeout || 3000;
return new Promise((resolve) => {
const req = mod.request(authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
timeout,
rejectUnauthorized: !config.allowSelfSignedCerts,
}, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
if (res.statusCode === 200) {
try {
const parsed = JSON.parse(data);
// Go server returns { token, role, username } on success
if (parsed.token && parsed.role) {
resolve({ role: parsed.role });
return;
}
// 2FA required — credentials are valid but need second factor
if (parsed.requires_2fa) {
resolve({ role: 'admin', requires2fa: true });
return;
}
} catch (_) { /* JSON parse error */ }
}
resolve(null);
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.write(body);
req.end();
});
}
/**
* Authenticate user with username and password.
* Supports both bcrypt (Node.js native) and PBKDF2 (Go server) hash formats.
@@ -87,6 +149,25 @@ async function authenticate(username, password) {
if (!user) {
// Timing-safe: do a real hash comparison to prevent user enumeration
await bcrypt.compare(password, DUMMY_HASH);
// Fallback: user may exist on Go server but not in local Node.js auth.db
const goResult = await tryGoServerAuth(username, password);
if (goResult) {
console.log(`[AUTH] Go server accepted credentials for '${username}' — creating local user`);
const bcryptHash = await hashPassword(password);
await db.createUser(username, bcryptHash, goResult.role || 'admin');
const created = await db.getUserByUsername(username);
if (created) {
await db.updateLastLogin(created.id);
return {
id: created.id,
username: created.username,
role: created.role,
totpRequired: false,
};
}
}
console.log(`[AUTH] Login failed: user '${username}' not found in database`);
return null;
}
@@ -99,14 +180,23 @@ async function authenticate(username, password) {
const { valid, needsMigration } = await verifyPasswordEx(password, user.password_hash);
if (!valid) {
console.log(`[AUTH] Login failed: password mismatch for '${username}' (hash type: ${hashType})`);
return null;
// Fallback: try Go server auth — password may have been changed on Go side
const goResult = await tryGoServerAuth(username, password);
if (goResult) {
console.log(`[AUTH] Go server accepted password for '${username}' — syncing local hash`);
const bcryptHash = await hashPassword(password);
await db.updateUserPassword(user.id, bcryptHash);
// Fall through to TOTP check and normal success path
} else {
console.log(`[AUTH] Login failed: password mismatch for '${username}' (hash type: ${hashType})`);
return null;
}
} else if (valid) {
console.log(`[AUTH] Login successful for '${username}'`);
}
console.log(`[AUTH] Login successful for '${username}'`);
// Auto-migrate PBKDF2 hash to bcrypt for future logins
if (needsMigration) {
if (valid && needsMigration) {
try {
const bcryptHash = await hashPassword(password);
await db.updateUserPassword(user.id, bcryptHash);
@@ -265,9 +355,37 @@ async function ensureDefaultAdmin() {
return false;
}
// No users at all — create the default admin
// No users at all — create the default admin.
// If no password from env or credential file, retry reading multiple times.
// The Go server may still be starting up and hasn't written .admin_credentials yet.
if (!defaultPassword) {
const retryDelays = [2000, 3000, 5000]; // 3 retries: 2s, 3s, 5s (total 10s max)
for (let i = 0; i < retryDelays.length; i++) {
console.log(`[AUTH] No admin password found. Waiting for Go server (attempt ${i + 1}/${retryDelays.length})...`);
await new Promise(resolve => setTimeout(resolve, retryDelays[i]));
defaultPassword = readAdminCredentialsFile() || '';
if (defaultPassword) {
console.log(`[AUTH] Found admin password from Go server on retry ${i + 1}`);
break;
}
}
}
const password = defaultPassword || require('crypto').randomBytes(16).toString('hex');
// If we generated the password (not from env or Go server), write it to a shared location
// so it can be discovered by users or other services.
if (!defaultPassword) {
const credsPath = path.join(config.dataDir, '.admin_credentials');
try {
const credsContent = `Admin Username: ${defaultUsername}\nAdmin Password: ${password}\nGenerated by: BetterDesk Console (Node.js)\nTimestamp: ${new Date().toISOString()}\n`;
fs.writeFileSync(credsPath, credsContent, { mode: 0o600 });
console.log(`[AUTH] Wrote generated admin credentials to ${credsPath}`);
} catch (e) {
console.warn(`[AUTH] Could not write .admin_credentials to ${credsPath}: ${e.message}`);
}
}
const hash = await hashPassword(password);
await db.createUser(defaultUsername, hash, 'admin');
+1
View File
@@ -45,6 +45,7 @@ const facade = {
updateDevice: (id, data) => adapter.updatePeer(id, data),
softDeletePeer: (id) => adapter.softDeletePeer(id),
deleteDevice: (id) => adapter.softDeletePeer(id),
getRenamedPeerId: (id) => adapter.getRenamedPeerId ? adapter.getRenamedPeerId(id) : null,
setBanStatus: (id, banned, reason) => adapter.setBanStatus(id, banned, reason),
getPeerStats: () => adapter.getPeerStats(),
getStats: () => adapter.getPeerStats(),
+55 -7
View File
@@ -217,6 +217,7 @@ function createSqliteAdapter(config) {
{ name: 'banned_at', sql: 'TEXT' },
{ name: 'banned_reason', sql: 'TEXT DEFAULT \'\'' },
{ name: 'folder_id', sql: 'INTEGER DEFAULT NULL' },
{ name: 'tags', sql: "TEXT DEFAULT ''" },
];
const existing = new Set(db.prepare('PRAGMA table_info(peer)').all().map(c => c.name));
for (const c of cols) {
@@ -793,6 +794,7 @@ function createSqliteAdapter(config) {
ban_reason: row.banned_reason || '',
folder_id: row.folder_id || null,
info: row.info || '',
tags: row.tags ? row.tags.split(',').filter(Boolean) : [],
};
}
@@ -836,7 +838,7 @@ function createSqliteAdapter(config) {
if (!tbl) return;
db.prepare(`
INSERT INTO peer (id, uuid, pk, info, ip, "user", status_online, last_online, created_at,
is_deleted, is_banned, banned_at, banned_reason)
is_deleted, is_banned, banned_at, banned_reason, tags)
SELECT
p.id,
COALESCE(p.uuid, ''),
@@ -855,15 +857,33 @@ function createSqliteAdapter(config) {
0,
CASE WHEN p.banned THEN 1 ELSE 0 END,
p.banned_at,
COALESCE(p.ban_reason, '')
COALESCE(p.ban_reason, ''),
COALESCE(p.tags, '')
FROM peers p
WHERE NOT p.soft_deleted
ON CONFLICT(id) DO UPDATE SET
status_online = excluded.status_online,
last_online = COALESCE(excluded.last_online, last_online),
info = CASE WHEN info IS NULL OR info = '{}' OR info = '' THEN excluded.info ELSE info END,
is_deleted = 0
is_deleted = 0,
tags = COALESCE(NULLIF(excluded.tags, ''), tags)
`).run();
// Clean up ghost entries from ID changes: remove peers whose ID appears
// as old_id in Go's id_change_history and no longer exists in Go's peers table.
const histTbl = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='id_change_history'").get();
if (histTbl) {
const result = db.prepare(`
DELETE FROM peer WHERE id IN (
SELECT h.old_id FROM id_change_history h
LEFT JOIN peers p ON p.id = h.old_id AND NOT p.soft_deleted
WHERE p.id IS NULL
)
`).run();
if (result.changes > 0) {
console.log(`[DB] syncGoPeersSqlite: cleaned up ${result.changes} ghost peer(s) from ID changes`);
}
}
} catch (err) {
if (!err.message.includes('no such table')) {
console.warn('[DB] syncGoPeersSqlite error:', err.message);
@@ -933,8 +953,8 @@ function createSqliteAdapter(config) {
version: goRow.version || ''
});
db.prepare(`
INSERT INTO peer (id, uuid, pk, info, ip, "user", status_online, last_online, created_at, is_deleted, is_banned, banned_at, banned_reason)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)
INSERT INTO peer (id, uuid, pk, info, ip, "user", status_online, last_online, created_at, is_deleted, is_banned, banned_at, banned_reason, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
uuid = COALESCE(NULLIF(excluded.uuid, ''), uuid),
pk = COALESCE(excluded.pk, pk),
@@ -943,7 +963,8 @@ function createSqliteAdapter(config) {
"user" = COALESCE(NULLIF(excluded."user", ''), "user"),
status_online = excluded.status_online,
last_online = COALESCE(excluded.last_online, last_online),
is_deleted = 0
is_deleted = 0,
tags = COALESCE(NULLIF(excluded.tags, ''), tags)
`).run(
goRow.id,
goRow.uuid || '',
@@ -956,7 +977,8 @@ function createSqliteAdapter(config) {
goRow.created_at || new Date().toISOString(),
goRow.banned ? 1 : 0,
goRow.banned_at || null,
goRow.ban_reason || ''
goRow.ban_reason || '',
goRow.tags || ''
);
row = db.prepare('SELECT * FROM peer WHERE id = ? AND is_deleted = 0').get(id);
}
@@ -1004,6 +1026,20 @@ function createSqliteAdapter(config) {
authDb.prepare('DELETE FROM device_group_peers WHERE peer_id = ?').run(id);
},
/**
* Check if a peer ID was renamed to a new ID. Returns the new_id if found,
* null otherwise. Used to reject registrations with stale/old IDs.
*/
getRenamedPeerId(oldId) {
try {
const db = openMain();
const tbl = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='id_change_history'").get();
if (!tbl) return null;
const row = db.prepare('SELECT new_id FROM id_change_history WHERE old_id = ? ORDER BY rowid DESC LIMIT 1').get(oldId);
return row ? row.new_id : null;
} catch (_) { return null; }
},
async setBanStatus(id, banned, reason = '') {
openMain().prepare(`
UPDATE peer SET is_banned = ?, banned_at = CASE WHEN ? THEN datetime('now') ELSE NULL END, banned_reason = ?
@@ -3087,6 +3123,7 @@ function createPostgresAdapter() {
ban_reason: row.banned_reason || '',
folder_id: row.folder_id || null,
info: typeof row.info === 'object' ? JSON.stringify(row.info) : (row.info || ''),
tags: row.tags ? (typeof row.tags === 'string' ? row.tags.split(',').filter(Boolean) : []) : [],
};
}
@@ -3288,6 +3325,17 @@ function createPostgresAdapter() {
await q('DELETE FROM device_group_peers WHERE peer_id = $1', [id]);
},
/**
* Check if a peer ID was renamed to a new ID. Returns the new_id if found,
* null otherwise. Used to reject registrations with stale/old IDs.
*/
async getRenamedPeerId(oldId) {
try {
const row = await q1('SELECT new_id FROM id_change_history WHERE old_id = $1 ORDER BY id DESC LIMIT 1', [oldId]);
return row ? row.new_id : null;
} catch (_) { return null; }
},
async setBanStatus(id, banned, reason = '') {
await q(`
UPDATE peer SET is_banned = $1, banned_at = CASE WHEN $1 THEN NOW() ELSE NULL END, banned_reason = $2
-6
View File
@@ -32,11 +32,5 @@
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (window.Languages) Languages.init();
});
</script>
`
}) %>
+20
View File
@@ -217,6 +217,26 @@
</div>
</div>
</div>
<div class="viewer-overlay session-2fa-overlay" style="display:none;">
<div class="overlay-card">
<div class="overlay-icon"><span class="material-icons">verified_user</span></div>
<h2 class="overlay-title">${_('remote.2fa_required') || '2FA Verification'}</h2>
<p class="overlay-subtitle">${_('remote.2fa_hint') || 'Enter the 6-digit code from your authenticator app'}</p>
<div class="password-form">
<div class="form-group">
<input type="text" class="form-input session-2fa-input"
placeholder="000000" autocomplete="one-time-code"
maxlength="6" inputmode="numeric" pattern="[0-9]*"
style="text-align:center;font-size:1.5rem;letter-spacing:0.5em;">
</div>
<p class="login-error session-2fa-error" style="display:none;"></p>
<button class="btn btn-primary btn-full session-btn-verify-2fa">
<span class="material-icons">verified</span>
${_('remote.verify') || 'Verify'}
</button>
</div>
</div>
</div>
<canvas class="session-canvas" tabindex="0"></canvas>
<div class="chat-panel session-chat-panel" style="display:none;">
<div class="chat-header">