mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`)
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "品牌标识",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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('');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,11 +32,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (window.Languages) Languages.init();
|
||||
});
|
||||
</script>
|
||||
`
|
||||
}) %>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user