diff --git a/.dev-pids b/.dev-pids index bf49aab..3a4f7e1 100644 --- a/.dev-pids +++ b/.dev-pids @@ -1,5 +1,5 @@ -18392 -18395 -18398 -18401 -18405 +36054 +36057 +36060 +36063 +36066 diff --git a/IT_asset_management/app/models/admin_user.py b/IT_asset_management/app/models/admin_user.py index dbcb7f0..871aa60 100644 --- a/IT_asset_management/app/models/admin_user.py +++ b/IT_asset_management/app/models/admin_user.py @@ -13,7 +13,16 @@ class AdminUser(UserMixin, db.Model): full_name = db.Column(db.String(200), nullable=True) email = db.Column(db.String(200), unique=True, nullable=False) password_hash = db.Column(db.String(256), nullable=False) - role = db.Column(db.String(30), default='admin') # admin, readonly + role = db.Column(db.String(30), default='readonly') # admin | editor | readonly + + @property + def is_admin(self): + return self.role == 'admin' + + @property + def is_editor(self): + """True for admin and editor roles (can manage assets).""" + return self.role in ('admin', 'editor') is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) last_login = db.Column(db.DateTime, nullable=True) diff --git a/IT_asset_management/app/utils/portal_sso.py b/IT_asset_management/app/utils/portal_sso.py index bff8326..9faa271 100644 --- a/IT_asset_management/app/utils/portal_sso.py +++ b/IT_asset_management/app/utils/portal_sso.py @@ -3,7 +3,12 @@ Portal SSO middleware for IT Asset Management. When the umbrella nginx verifies the portal JWT it sets two headers: X-Auth-Username — the portal username - X-Auth-Role — 'admin' or 'user' + X-Auth-Role — 'admin' | 'advanced' | 'standard' + +Portal role → IT Assets local role mapping: + admin → admin (full access including settings) + advanced → editor (manage assets/assignments, no system settings) + standard → readonly (view-only) This before_request handler reads those headers and auto-logs in the corresponding local AdminUser, creating them on first access if needed. @@ -32,11 +37,20 @@ def init_portal_sso(app): login_user(user, remember=False) +def _portal_role_to_local(portal_role): + """Map a portal role string to the IT Assets local role.""" + if portal_role == 'admin': + return 'admin' + if portal_role == 'advanced': + return 'editor' + return 'readonly' # 'standard' or anything unknown + + def _get_or_create_user(username, role): from app.models.admin_user import AdminUser from app.extensions import db - target_role = 'admin' if role == 'admin' else 'readonly' + target_role = _portal_role_to_local(role) try: user = AdminUser.query.filter_by(username=username).first() if not user: diff --git a/NetworkView/backend/src/db.js b/NetworkView/backend/src/db.js index 74b4f6d..bf85b54 100644 --- a/NetworkView/backend/src/db.js +++ b/NetworkView/backend/src/db.js @@ -120,4 +120,14 @@ db.exec(` ); `); +// ── Site permissions (advanced role: one site per user) ────────────────────── +db.exec(` + CREATE TABLE IF NOT EXISTS site_permissions ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + granted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id) + ); +`); + module.exports = db; diff --git a/NetworkView/backend/src/index.js b/NetworkView/backend/src/index.js index fcbf3c9..ad29d2c 100644 --- a/NetworkView/backend/src/index.js +++ b/NetworkView/backend/src/index.js @@ -11,25 +11,55 @@ app.use(express.json()); app.use(morgan('dev')); // ── Actor middleware ────────────────────────────────────────────────────────── -// The external main app sets x-user-id + x-username after token verification. +// The external main app sets x-user-id + x-username + x-auth-role after token verification. // Direct calls can use x-api-key (validated against users.api_key). +const VALID_ROLES = new Set(['admin', 'advanced', 'standard']); const db = require('./db'); + app.use((req, _res, next) => { const userId = req.headers['x-user-id']; const username = req.headers['x-username']; const apiKey = req.headers['x-api-key']; + const headerRole = (req.headers['x-auth-role'] || '').trim(); + const portalRole = VALID_ROLES.has(headerRole) ? headerRole : 'standard'; if (userId && username) { - req.actor = { id: userId, username: String(username) }; + req.actor = { id: String(userId), username: String(username), role: portalRole }; + + // ── Portal SSO sync ────────────────────────────────────────────────────── + // Upsert the user row so the local users table always reflects the portal. + // Map portal roles → NV local roles: admin→admin, advanced→advanced, standard→viewer + const localRole = portalRole === 'admin' ? 'admin' + : portalRole === 'advanced' ? 'advanced' + : 'viewer'; + try { + const existing = db.prepare('SELECT id, role FROM users WHERE username = ?').get(username); + if (!existing) { + const { v4: uuidv4 } = require('uuid'); + db.prepare( + 'INSERT INTO users (id, username, role, is_active) VALUES (?, ?, ?, 1)' + ).run(String(userId), String(username), localRole); + } else if (existing.role !== localRole) { + db.prepare('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE username = ?') + .run(localRole, String(username)); + } + } catch (_) { /* best-effort; never break the request */ } + } else if (apiKey) { - const user = db.prepare('SELECT id, username FROM users WHERE api_key = ? AND is_active = 1').get(apiKey); - req.actor = user ? { id: user.id, username: user.username } : { id: null, username: 'api-key-invalid' }; + const user = db.prepare('SELECT id, username, role FROM users WHERE api_key = ? AND is_active = 1').get(apiKey); + req.actor = user + ? { id: user.id, username: user.username, role: 'admin' } + : { id: null, username: 'api-key-invalid', role: 'standard' }; } else { - req.actor = { id: null, username: 'anonymous' }; + req.actor = { id: null, username: 'anonymous', role: 'standard' }; } next(); }); +// ── RBAC helpers (used by route files) ─────────────────────────────────────── +// requireRole / requireWriteAccess are defined in middleware/rbac.js +app.locals.requireRole = require('./middleware/rbac').requireRole; + // API routes app.use('/api/sites', require('./routes/sites')); app.use('/api/rooms', require('./routes/rooms')); diff --git a/NetworkView/backend/src/middleware/rbac.js b/NetworkView/backend/src/middleware/rbac.js new file mode 100644 index 0000000..4b4bec6 --- /dev/null +++ b/NetworkView/backend/src/middleware/rbac.js @@ -0,0 +1,95 @@ +/** + * RBAC middleware for NetworkView. + * + * Role tiers (from portal via X-Auth-Role header): + * admin – full CRUD on everything + * advanced – CRUD scoped to ONE assigned site (site_permissions table) + * standard – read-only (GET only) + * + * Helpers exported: + * requireRole(minRole) – rejects if actor tier < minRole + * requireWriteAccess(getSiteId) – admin: always; advanced: only their site; + * standard: never + */ + +const db = require('../db'); + +// Numeric tier so we can do >= comparisons +const TIERS = { standard: 0, advanced: 1, admin: 2 }; + +/** Reject if actor's role tier is below minRole. */ +function requireRole(minRole) { + const min = TIERS[minRole] ?? 0; + return (req, res, next) => { + const tier = TIERS[req.actor?.role] ?? 0; + if (tier < min) { + return res.status(403).json({ + error: `Requires ${minRole} role (you have ${req.actor?.role ?? 'none'})`, + }); + } + next(); + }; +} + +/** + * requireWriteAccess(getSiteIdFn) + * + * getSiteIdFn(req) → siteId string (or null/undefined) + * – called only when role is 'advanced' to resolve which site is being touched. + * – for 'admin' it is never called. + * – for 'standard' it is never called (rejected immediately). + * + * Usage: + * router.post('/', requireWriteAccess(req => req.body.site_id), handler) + * router.delete('/:id', requireWriteAccess(req => { + * const room = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(req.params.id); + * return room?.site_id; + * }), handler) + */ +function requireWriteAccess(getSiteIdFn) { + return (req, res, next) => { + const role = req.actor?.role ?? 'standard'; + + if (role === 'admin') return next(); + + if (role !== 'advanced') { + return res.status(403).json({ error: 'Write access requires advanced or admin role.' }); + } + + // advanced: check site_permissions + const userId = req.actor?.id; + if (!userId) { + return res.status(403).json({ error: 'No user identity for permission check.' }); + } + + const perm = db.prepare( + 'SELECT site_id FROM site_permissions WHERE user_id = ?' + ).get(userId); + + if (!perm) { + return res.status(403).json({ + error: 'Advanced user has no site assigned. Ask an admin to assign a site.', + }); + } + + // Resolve the target site from the request + let targetSiteId; + try { + targetSiteId = getSiteIdFn(req); + } catch (_) { + targetSiteId = null; + } + + if (!targetSiteId || targetSiteId !== perm.site_id) { + return res.status(403).json({ + error: 'You can only modify your assigned site.', + }); + } + + // Attach for downstream use + req.permittedSiteId = perm.site_id; + next(); + }; +} + +module.exports = { requireRole, requireWriteAccess, TIERS }; diff --git a/NetworkView/backend/src/routes/components.js b/NetworkView/backend/src/routes/components.js index ae486fd..4f383d5 100644 --- a/NetworkView/backend/src/routes/components.js +++ b/NetworkView/backend/src/routes/components.js @@ -2,8 +2,25 @@ const express = require('express'); const { v4: uuidv4 } = require('uuid'); const db = require('../db'); const { logAudit } = require('../audit'); +const { requireWriteAccess } = require('../middleware/rbac'); const router = express.Router(); +const siteIdFromComponentBody = req => { + if (!req.body.rack_id) return null; + const rack = db.prepare('SELECT room_id FROM racks WHERE id=?').get(req.body.rack_id); + if (!rack?.room_id) return null; + const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(rack.room_id); + return r?.site_id; +}; +const siteIdFromComponentPk = req => { + const c = db.prepare('SELECT rack_id FROM components WHERE id=?').get(req.params.id ?? req.params.componentId); + if (!c?.rack_id) return null; + const rack = db.prepare('SELECT room_id FROM racks WHERE id=?').get(c.rack_id); + if (!rack?.room_id) return null; + const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(rack.room_id); + return r?.site_id; +}; + function checkPositionOverlap(rackId, position, heightUnits, excludeId = null) { const end = position + heightUnits - 1; const existing = db.prepare(` @@ -54,7 +71,7 @@ router.get('/:id', (req, res) => { }); // POST /api/components -router.post('/', (req, res) => { +router.post('/', requireWriteAccess(siteIdFromComponentBody), (req, res) => { const { rack_id, name, type, position, height_units, manufacturer, model, serial_number, asset_tag, @@ -103,7 +120,7 @@ router.post('/', (req, res) => { }); // PUT /api/components/:id -router.put('/:id', (req, res) => { +router.put('/:id', requireWriteAccess(siteIdFromComponentPk), (req, res) => { const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id); if (!component) return res.status(404).json({ error: 'Component not found' }); @@ -162,7 +179,7 @@ router.put('/:id', (req, res) => { }); // DELETE /api/components/:id -router.delete('/:id', (req, res) => { +router.delete('/:id', requireWriteAccess(siteIdFromComponentPk), (req, res) => { const component = db.prepare('SELECT * FROM components WHERE id = ?').get(req.params.id); if (!component) return res.status(404).json({ error: 'Component not found' }); @@ -180,7 +197,7 @@ router.get('/:id/ports', (req, res) => { }); // POST /api/components/:id/ports -router.post('/:id/ports', (req, res) => { +router.post('/:id/ports', requireWriteAccess(siteIdFromComponentPk), (req, res) => { const component = db.prepare('SELECT id, type, port_count FROM components WHERE id = ?').get(req.params.id); if (!component) return res.status(404).json({ error: 'Component not found' }); @@ -216,7 +233,7 @@ router.post('/:id/ports', (req, res) => { }); // PUT /api/components/:componentId/ports/:portId -router.put('/:componentId/ports/:portId', (req, res) => { +router.put('/:componentId/ports/:portId', requireWriteAccess(siteIdFromComponentPk), (req, res) => { const port = db.prepare('SELECT * FROM ports WHERE id = ? AND component_id = ?').get(req.params.portId, req.params.componentId); if (!port) return res.status(404).json({ error: 'Port not found' }); @@ -249,7 +266,7 @@ router.put('/:componentId/ports/:portId', (req, res) => { }); // DELETE /api/components/:componentId/ports/:portId -router.delete('/:componentId/ports/:portId', (req, res) => { +router.delete('/:componentId/ports/:portId', requireWriteAccess(siteIdFromComponentPk), (req, res) => { db.prepare('DELETE FROM ports WHERE id = ? AND component_id = ?').run(req.params.portId, req.params.componentId); res.json({ ok: true }); }); diff --git a/NetworkView/backend/src/routes/racks.js b/NetworkView/backend/src/routes/racks.js index f1d950a..7122945 100644 --- a/NetworkView/backend/src/routes/racks.js +++ b/NetworkView/backend/src/routes/racks.js @@ -2,8 +2,21 @@ const express = require('express'); const { v4: uuidv4 } = require('uuid'); const db = require('../db'); const { logAudit } = require('../audit'); +const { requireWriteAccess } = require('../middleware/rbac'); const router = express.Router(); +const siteIdFromRackBody = req => { + if (!req.body.room_id) return null; + const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(req.body.room_id); + return r?.site_id; +}; +const siteIdFromRackPk = req => { + const rack = db.prepare('SELECT room_id FROM racks WHERE id=?').get(req.params.id); + if (!rack?.room_id) return null; + const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(rack.room_id); + return r?.site_id; +}; + // GET /api/racks?roomId= router.get('/', (req, res) => { const { roomId } = req.query; @@ -45,7 +58,7 @@ router.get('/:id', (req, res) => { }); // POST /api/racks -router.post('/', (req, res) => { +router.post('/', requireWriteAccess(siteIdFromRackBody), (req, res) => { const { room_id, name, total_units, manufacturer, model, notes } = req.body; if (!name) return res.status(400).json({ error: 'name is required' }); @@ -66,7 +79,7 @@ router.post('/', (req, res) => { }); // PUT /api/racks/:id -router.put('/:id', (req, res) => { +router.put('/:id', requireWriteAccess(siteIdFromRackPk), (req, res) => { const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id); if (!rack) return res.status(404).json({ error: 'Rack not found' }); @@ -98,7 +111,7 @@ router.put('/:id', (req, res) => { }); // DELETE /api/racks/:id -router.delete('/:id', (req, res) => { +router.delete('/:id', requireWriteAccess(siteIdFromRackPk), (req, res) => { const rack = db.prepare('SELECT * FROM racks WHERE id = ?').get(req.params.id); if (!rack) return res.status(404).json({ error: 'Rack not found' }); diff --git a/NetworkView/backend/src/routes/rooms.js b/NetworkView/backend/src/routes/rooms.js index 4fe7f23..98beccb 100644 --- a/NetworkView/backend/src/routes/rooms.js +++ b/NetworkView/backend/src/routes/rooms.js @@ -2,8 +2,16 @@ const express = require('express'); const { v4: uuidv4 } = require('uuid'); const db = require('../db'); const { logAudit } = require('../audit'); +const { requireWriteAccess } = require('../middleware/rbac'); const router = express.Router(); +// Resolvers for site_id from a room id or room body +const siteIdFromRoomId = req => req.body.site_id; +const siteIdFromRoomPk = req => { + const r = db.prepare('SELECT site_id FROM rooms WHERE id=?').get(req.params.id); + return r?.site_id; +}; + // GET /api/rooms?siteId= router.get('/', (req, res) => { const { siteId } = req.query; @@ -39,7 +47,7 @@ router.get('/:id', (req, res) => { }); // POST /api/rooms -router.post('/', (req, res) => { +router.post('/', requireWriteAccess(siteIdFromRoomId), (req, res) => { const { site_id, name, notes } = req.body; if (!site_id || !name) return res.status(400).json({ error: 'site_id and name are required' }); @@ -56,7 +64,7 @@ router.post('/', (req, res) => { }); // PUT /api/rooms/:id -router.put('/:id', (req, res) => { +router.put('/:id', requireWriteAccess(siteIdFromRoomPk), (req, res) => { const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id); if (!room) return res.status(404).json({ error: 'Room not found' }); @@ -78,7 +86,7 @@ router.put('/:id', (req, res) => { }); // DELETE /api/rooms/:id -router.delete('/:id', (req, res) => { +router.delete('/:id', requireWriteAccess(siteIdFromRoomPk), (req, res) => { const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(req.params.id); if (!room) return res.status(404).json({ error: 'Room not found' }); diff --git a/NetworkView/backend/src/routes/sites.js b/NetworkView/backend/src/routes/sites.js index 586d5d9..8fcea87 100644 --- a/NetworkView/backend/src/routes/sites.js +++ b/NetworkView/backend/src/routes/sites.js @@ -2,8 +2,12 @@ const express = require('express'); const { v4: uuidv4 } = require('uuid'); const db = require('../db'); const { logAudit } = require('../audit'); +const { requireRole, requireWriteAccess } = require('../middleware/rbac'); const router = express.Router(); +// For advanced users: the site itself IS the permission boundary +// PUT is allowed if they own that site; POST (create) and DELETE are admin-only. + // GET /api/sites router.get('/', (req, res) => { const sites = db.prepare(` @@ -33,8 +37,8 @@ router.get('/:id', (req, res) => { res.json({ ...site, rooms }); }); -// POST /api/sites -router.post('/', (req, res) => { +// POST /api/sites — admin only (creating a new site is a structural change) +router.post('/', requireRole('admin'), (req, res) => { const { name, location, notes } = req.body; if (!name) return res.status(400).json({ error: 'Name is required' }); @@ -47,8 +51,8 @@ router.post('/', (req, res) => { res.status(201).json(created); }); -// PUT /api/sites/:id -router.put('/:id', (req, res) => { +// PUT /api/sites/:id — admin always; advanced only if this is their assigned site +router.put('/:id', requireWriteAccess(req => req.params.id), (req, res) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).json({ error: 'Site not found' }); @@ -71,8 +75,8 @@ router.put('/:id', (req, res) => { res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id)); }); -// DELETE /api/sites/:id -router.delete('/:id', (req, res) => { +// DELETE /api/sites/:id — admin only +router.delete('/:id', requireRole('admin'), (req, res) => { const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(req.params.id); if (!site) return res.status(404).json({ error: 'Site not found' }); diff --git a/NetworkView/backend/src/routes/users.js b/NetworkView/backend/src/routes/users.js index c89219e..7e89905 100644 --- a/NetworkView/backend/src/routes/users.js +++ b/NetworkView/backend/src/routes/users.js @@ -1,40 +1,121 @@ /** * /api/users * - * Manages NetworkView users. Authentication is expected to be handled by - * the external main app; this API accepts an `x-user-id` / `x-username` - * header (set by the gateway after verifying the bearer token) and an - * `x-api-key` header for direct service-to-service calls. + * Manages NetworkView users. The GET / endpoint merges data from: + * 1. The local NV users table (api_key status, site_permissions) + * 2. The portal's /api/internal/nv-users endpoint (all portal users with NV access) * - * Roles: admin | editor | viewer + * This ensures portal users appear in Settings even before their first NV login. */ const express = require('express'); const { v4: uuidv4 } = require('uuid'); const crypto = require('crypto'); +const http = require('http'); const db = require('../db'); const { logAudit } = require('../audit'); +const { requireRole } = require('../middleware/rbac'); const router = express.Router(); +// ── Config ──────────────────────────────────────────────────────────────────── +const PORTAL_URL = process.env.PORTAL_INTERNAL_URL || 'http://localhost:5001'; +const INTERNAL_TOKEN = process.env.INTERNAL_SYNC_SECRET || 'change-this-internal-secret'; + // ── Helpers ────────────────────────────────────────────────────────────────── -const safeUser = u => ({ - id: u.id, - username: u.username, - email: u.email, - role: u.role, - is_active: u.is_active, - has_api_key: !!u.api_key, - created_at: u.created_at, - updated_at: u.updated_at, -}); +const safeUser = u => { + const perm = db.prepare( + 'SELECT sp.site_id, s.name AS site_name FROM site_permissions sp LEFT JOIN sites s ON s.id = sp.site_id WHERE sp.user_id = ?' + ).get(u.id); + return { + id: u.id, + username: u.username, + email: u.email, + role: u.role, + is_active: Boolean(u.is_active), + has_api_key: !!u.api_key, + site_permission: perm ? { site_id: perm.site_id, site_name: perm.site_name } : null, + created_at: u.created_at, + updated_at: u.updated_at, + }; +}; + +/** Fetch portal NV-users list. Returns [] on any error (best-effort). */ +function fetchPortalUsers() { + return new Promise(resolve => { + const url = new URL('/api/internal/nv-users', PORTAL_URL); + const req = http.request( + { hostname: url.hostname, port: url.port || 80, path: url.pathname, + method: 'GET', headers: { 'X-Internal-Token': INTERNAL_TOKEN } }, + res => { + let body = ''; + res.on('data', c => { body += c; }); + res.on('end', () => { + try { resolve(res.statusCode === 200 ? JSON.parse(body) : []); } + catch (_) { resolve([]); } + }); + } + ); + req.on('error', () => resolve([])); + req.setTimeout(3000, () => { req.destroy(); resolve([]); }); + req.end(); + }); +} // ── GET /api/users ──────────────────────────────────────────────────────────── -router.get('/', (req, res) => { - const users = db.prepare('SELECT * FROM users ORDER BY username').all(); - res.json(users.map(safeUser)); +// Merges portal users (with NV access) and local NV user rows. +router.get('/', async (req, res) => { + // Local NV users keyed by username + const localUsers = db.prepare('SELECT * FROM users ORDER BY username').all(); + const localByUsername = Object.fromEntries(localUsers.map(u => [u.username, u])); + + // Portal users (all who have NV access) + const portalUsers = await fetchPortalUsers(); + + const seen = new Set(); + const result = []; + + for (const pu of portalUsers) { + seen.add(pu.username); + const local = localByUsername[pu.username]; + + if (local) { + // Merge: use portal role (source of truth) but keep NV-local extras + result.push(safeUser({ ...local, role: _mapPortalRole(pu.nv_role) })); + } else { + // Portal user not yet in NV — show with no local extras + const perm = null; + result.push({ + id: pu.portal_id, + username: pu.username, + email: pu.email, + role: _mapPortalRole(pu.nv_role), + is_active: true, + has_api_key: false, + site_permission: null, + created_at: null, + updated_at: null, + not_yet_synced: true, // hint for UI + }); + } + } + + // Include any local-only users not in portal (e.g. API-key-only accounts) + for (const u of localUsers) { + if (!seen.has(u.username)) result.push(safeUser(u)); + } + + result.sort((a, b) => a.username.localeCompare(b.username)); + res.json(result); }); +/** Map portal role string → NV local role string */ +function _mapPortalRole(role) { + if (role === 'admin') return 'admin'; + if (role === 'advanced') return 'advanced'; + return 'viewer'; +} + // ── GET /api/users/:id ──────────────────────────────────────────────────────── router.get('/:id', (req, res) => { const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); @@ -113,6 +194,41 @@ router.delete('/:id', (req, res) => { res.json({ ok: true }); }); +// ── GET /api/users/:id/site-permission ─────────────────────────────────────── +router.get('/:id/site-permission', requireRole('admin'), (req, res) => { + const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); + if (!user) return res.status(404).json({ error: 'User not found' }); + const perm = db.prepare( + 'SELECT sp.site_id, s.name AS site_name FROM site_permissions sp LEFT JOIN sites s ON s.id = sp.site_id WHERE sp.user_id = ?' + ).get(req.params.id); + res.json(perm ?? { site_id: null, site_name: null }); +}); + +// ── PUT /api/users/:id/site-permission ─────────────────────────────────────── +// Body: { site_id: "" } or { site_id: null } to clear +router.put('/:id/site-permission', requireRole('admin'), (req, res) => { + const user = db.prepare('SELECT id, username FROM users WHERE id = ?').get(req.params.id); + if (!user) return res.status(404).json({ error: 'User not found' }); + + const { site_id } = req.body; + + if (!site_id) { + db.prepare('DELETE FROM site_permissions WHERE user_id = ?').run(req.params.id); + logAudit(req, { action: 'update', entityType: 'user', entityId: user.id, entityName: user.username, changes: 'site_permission cleared' }); + return res.json({ ok: true, site_id: null }); + } + + const site = db.prepare('SELECT id, name FROM sites WHERE id = ?').get(site_id); + if (!site) return res.status(404).json({ error: 'Site not found' }); + + db.prepare( + 'INSERT INTO site_permissions (user_id, site_id) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET site_id=excluded.site_id, granted_at=CURRENT_TIMESTAMP' + ).run(req.params.id, site_id); + + logAudit(req, { action: 'update', entityType: 'user', entityId: user.id, entityName: user.username, changes: `site_permission set to ${site.name}` }); + res.json({ ok: true, site_id, site_name: site.name }); +}); + // ── POST /api/users/:id/api-key (generate / rotate) ───────────────────────── router.post('/:id/api-key', (req, res) => { const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id); diff --git a/NetworkView/frontend/src/api.ts b/NetworkView/frontend/src/api.ts index eb3aed8..dd09d8a 100644 --- a/NetworkView/frontend/src/api.ts +++ b/NetworkView/frontend/src/api.ts @@ -84,6 +84,10 @@ export const rotateApiKey = (id: string) => request<{ api_key: string }>(`/users/${id}/api-key`, { method: 'POST' }); export const revokeApiKey = (id: string) => request(`/users/${id}/api-key`, { method: 'DELETE' }); +export const setSitePermission = (userId: string, siteId: string | null) => + request<{ ok: boolean; site_id: string | null }>(`/users/${userId}/site-permission`, { + method: 'PUT', body: JSON.stringify({ site_id: siteId }), + }); // --- Audit --- export const getAuditLog = (params: { diff --git a/NetworkView/frontend/src/components/Sidebar.tsx b/NetworkView/frontend/src/components/Sidebar.tsx index caf4c89..3c856b6 100644 --- a/NetworkView/frontend/src/components/Sidebar.tsx +++ b/NetworkView/frontend/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ export default function Sidebar() { const [loading, setLoading] = useState(true); const [addSiteName, setAddSiteName] = useState(''); const [showAddSite, setShowAddSite] = useState(false); + const [addSiteErr, setAddSiteErr] = useState(null); const navigate = useNavigate(); const location = useLocation(); @@ -74,11 +75,16 @@ export default function Sidebar() { const handleAddSite = async (e: React.FormEvent) => { e.preventDefault(); if (!addSiteName.trim()) return; - const site = await api.createSite({ name: addSiteName.trim() }); - setAddSiteName(''); - setShowAddSite(false); - await loadTree(); - navigate(`/sites/${site.id}`); + setAddSiteErr(null); + try { + const site = await api.createSite({ name: addSiteName.trim() }); + setAddSiteName(''); + setShowAddSite(false); + await loadTree(); + navigate(`/sites/${site.id}`); + } catch (ex: unknown) { + setAddSiteErr(ex instanceof Error ? ex.message : String(ex)); + } }; useEffect(() => { @@ -147,18 +153,21 @@ export default function Sidebar() { {showAddSite && ( -
- setAddSiteName(e.target.value)} - className="inline-input" - /> - - -
+ <> +
+ setAddSiteName(e.target.value)} + className="inline-input" + /> + + +
+ {addSiteErr &&
{addSiteErr}
} + )} {loading &&
Loading...
} @@ -229,6 +238,13 @@ export default function Sidebar() { ⚙ Settings + + ⬡ Portal +

- User accounts for this NetworkView instance. Authentication is delegated to your central app — - set X-User-Id + X-Username headers on API requests, or use per-user API keys. + Users are automatically synced from the Enterprise Portal on each login. + Roles: admin — full access · advanced — CRUD on one assigned site · viewer — read-only.

{showAdd && ( @@ -115,9 +134,9 @@ function UsersPanel() { className="modal-select" value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))} > - - - + + + {err &&
{err}
}
@@ -142,19 +161,43 @@ function UsersPanel() { - + {users.map(u => ( - + + ))} {users.length === 0 && ( - + )}
UsernameEmailRoleStatusAPI KeyActionsUsernameEmailRoleAssigned SiteStatusAPI KeyActions
{u.username}{u.username}{u.not_yet_synced && ( + + (portal only) + + )} {u.email ?? '—'} - {u.role} + {ROLE_LABEL[u.role] ?? u.role} + {u.role === 'advanced' ? ( + + ) : ( + + {u.role === 'admin' ? 'all sites' : '—'} + + )} + {u.is_active ? 'active' : 'inactive'} @@ -177,7 +220,7 @@ function UsersPanel() {
No users yet
No users yet
diff --git a/NetworkView/frontend/src/types.ts b/NetworkView/frontend/src/types.ts index 8e87de0..0d0c255 100644 --- a/NetworkView/frontend/src/types.ts +++ b/NetworkView/frontend/src/types.ts @@ -143,11 +143,13 @@ export interface User { id: string; username: string; email?: string; - role: 'admin' | 'editor' | 'viewer'; + role: 'admin' | 'advanced' | 'viewer'; is_active: boolean; has_api_key: boolean; - created_at: string; - updated_at: string; + site_permission: { site_id: string; site_name: string } | null; + not_yet_synced?: boolean; + created_at: string | null; + updated_at: string | null; } export interface AuditEntry { diff --git a/Server_Monitorizare_v2/app/utils/portal_sso.py b/Server_Monitorizare_v2/app/utils/portal_sso.py index 25c449a..dea5dbd 100644 --- a/Server_Monitorizare_v2/app/utils/portal_sso.py +++ b/Server_Monitorizare_v2/app/utils/portal_sso.py @@ -3,7 +3,12 @@ Portal SSO middleware for Server Monitor. When the umbrella nginx verifies the portal JWT it sets two headers: X-Auth-Username — the portal username - X-Auth-Role — 'admin' or 'user' + X-Auth-Role — 'admin' | 'advanced' | 'standard' + +Portal role → Server Monitor access level: + admin — full access: view all, run Ansible, manage config + advanced — can view all data and trigger safe operations + standard — read-only dashboard view This before_request handler stores them in Flask's g so templates and routes can access the current user without a local user DB. @@ -17,11 +22,16 @@ def init_portal_sso(app): @app.before_request def _portal_sso(): g.portal_user = request.headers.get('X-Auth-Username', '').strip() or None - g.portal_role = request.headers.get('X-Auth-Role', 'user').strip() + raw_role = request.headers.get('X-Auth-Role', 'standard').strip() + # Normalise to the three known tiers; unknown values fall back to 'standard' + g.portal_role = raw_role if raw_role in ('admin', 'advanced', 'standard') else 'standard' @app.context_processor def _inject_portal_user(): return { 'portal_user': getattr(g, 'portal_user', None), - 'portal_role': getattr(g, 'portal_role', 'user'), + 'portal_role': getattr(g, 'portal_role', 'standard'), + # Convenience booleans available in all templates + 'portal_is_admin': getattr(g, 'portal_role', 'standard') == 'admin', + 'portal_is_advanced': getattr(g, 'portal_role', 'standard') in ('admin', 'advanced'), } diff --git a/digiserver-v2/app/blueprints/admin.py b/digiserver-v2/app/blueprints/admin.py index 387251b..7511387 100644 --- a/digiserver-v2/app/blueprints/admin.py +++ b/digiserver-v2/app/blueprints/admin.py @@ -9,26 +9,15 @@ from typing import Optional from app.extensions import db, bcrypt from app.models import User, Player, Content, ServerLog, Playlist, HTTPSConfig +from app.models.playlist_permission import PlaylistPermission from app.utils.logger import log_action from app.utils.caddy_manager import CaddyConfigGenerator from app.utils.nginx_config_reader import get_nginx_status +from app.utils.access import admin_required, editor_required admin_bp = Blueprint('admin', __name__, url_prefix='/admin') - -def admin_required(f): - """Decorator to require admin role for route access.""" - @wraps(f) - def decorated_function(*args, **kwargs): - if not current_user.is_authenticated: - flash('Please login to access this page.', 'warning') - return redirect(url_for('auth.login')) - if current_user.role != 'admin': - log_action('warning', f'Unauthorized admin access attempt by {current_user.username}') - flash('You do not have permission to access this page.', 'danger') - return redirect(url_for('main.dashboard')) - return f(*args, **kwargs) - return decorated_function +# admin_required and editor_required imported from app.utils.access @admin_bp.route('/') @@ -203,6 +192,58 @@ def user_management(): return redirect(url_for('admin.admin_panel')) +# ── Playlist permissions (viewer-role users) ─────────────────────────────────── + +@admin_bp.route('/user//playlist-permissions') +@login_required +@admin_required +def playlist_permissions(user_id: int): + """Show/manage which playlists a viewer-role user may edit.""" + user = User.query.get_or_404(user_id) + playlists = Playlist.query.order_by(Playlist.name).all() + granted_ids = { + p.playlist_id + for p in PlaylistPermission.query.filter_by(user_id=user_id).all() + } + return render_template( + 'admin/playlist_permissions.html', + target_user=user, + playlists=playlists, + granted_ids=granted_ids, + ) + + +@admin_bp.route('/user//playlist-permissions/save', methods=['POST']) +@login_required +@admin_required +def save_playlist_permissions(user_id: int): + """Save (overwrite) the playlist edit permissions for a viewer user.""" + user = User.query.get_or_404(user_id) + all_playlists = Playlist.query.all() + + # The form sends one checkbox per playlist: name="playlist_" value="1" + new_ids = { + pl.id for pl in all_playlists + if request.form.get(f'playlist_{pl.id}') == '1' + } + + # Current grants + existing = {p.playlist_id: p for p in PlaylistPermission.query.filter_by(user_id=user_id).all()} + + # Add new grants + for pid in new_ids - set(existing.keys()): + db.session.add(PlaylistPermission(user_id=user_id, playlist_id=pid)) + + # Remove revoked grants + for pid in set(existing.keys()) - new_ids: + db.session.delete(existing[pid]) + + db.session.commit() + log_action('info', f'Playlist permissions updated for user "{user.username}" by {current_user.username}') + flash(f'Playlist permissions updated for "{user.username}".', 'success') + return redirect(url_for('admin.playlist_permissions', user_id=user_id)) + + @admin_bp.route('/user//password', methods=['POST']) @login_required @admin_required diff --git a/digiserver-v2/app/blueprints/content.py b/digiserver-v2/app/blueprints/content.py index 9c3d77e..e832381 100644 --- a/digiserver-v2/app/blueprints/content.py +++ b/digiserver-v2/app/blueprints/content.py @@ -1,7 +1,7 @@ """Content blueprint - New playlist-centric workflow.""" from flask import (Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app) -from flask_login import login_required +from flask_login import login_required, current_user from werkzeug.utils import secure_filename from typing import Optional import os @@ -15,6 +15,7 @@ from app.models import Content, Playlist, Player from app.models.playlist import playlist_content from app.utils.logger import log_action from app.utils.uploads import process_video_file, set_upload_progress +from app.utils.access import editor_required, can_edit_playlist, get_editable_playlist_ids # Store for background processing status _background_tasks = {} @@ -30,12 +31,14 @@ def content_list(): media_files = Content.query.order_by(Content.uploaded_at.desc()).limit(3).all() # Only last 3 total_media_count = Content.query.count() # Total count for display players = Player.query.order_by(Player.name).all() - + editable_ids = get_editable_playlist_ids(current_user) + return render_template('content/content_list_new.html', playlists=playlists, media_files=media_files, total_media_count=total_media_count, - players=players) + players=players, + editable_ids=editable_ids) @content_bp.route('/media-library') @@ -68,6 +71,7 @@ def media_library(): @content_bp.route('/media//delete', methods=['POST']) @login_required +@editor_required def delete_media(media_id: int): """Delete a media file and remove it from all playlists.""" try: @@ -132,6 +136,7 @@ def delete_media(media_id: int): @content_bp.route('/playlist/create', methods=['POST']) @login_required +@editor_required def create_playlist(): """Create a new playlist.""" try: @@ -170,6 +175,7 @@ def create_playlist(): @content_bp.route('/playlist//delete', methods=['POST']) @login_required +@editor_required def delete_playlist(playlist_id: int): """Delete a playlist.""" playlist = Playlist.query.get_or_404(playlist_id) @@ -200,27 +206,32 @@ def delete_playlist(playlist_id: int): def manage_playlist_content(playlist_id: int): """Manage content in a specific playlist.""" playlist = Playlist.query.get_or_404(playlist_id) - + can_edit = can_edit_playlist(current_user, playlist_id) + # Get content in playlist (ordered) playlist_content = playlist.get_content_ordered() - + # Get all available content not in this playlist. # Web links are created on demand per playlist, so they are not offered # as reusable library items here. all_content = Content.query.filter(Content.content_type != 'weblink').all() playlist_content_ids = {c.id for c in playlist_content} available_content = [c for c in all_content if c.id not in playlist_content_ids] - + return render_template('content/manage_playlist_content.html', playlist=playlist, playlist_content=playlist_content, - available_content=available_content) + available_content=available_content, + can_edit=can_edit) @content_bp.route('/playlist//add-content', methods=['POST']) @login_required def add_content_to_playlist(playlist_id: int): """Add content to playlist.""" + if not can_edit_playlist(current_user, playlist_id): + flash('You do not have permission to edit this playlist.', 'danger') + return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) playlist = Playlist.query.get_or_404(playlist_id) try: @@ -351,6 +362,9 @@ def add_weblink(): @login_required def add_weblink_to_playlist(playlist_id: int): """Create a web link content item and add it to the playlist.""" + if not can_edit_playlist(current_user, playlist_id): + flash('You do not have permission to edit this playlist.', 'danger') + return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) playlist = Playlist.query.get_or_404(playlist_id) try: @@ -417,6 +431,9 @@ def add_weblink_to_playlist(playlist_id: int): @login_required def remove_content_from_playlist(playlist_id: int, content_id: int): """Remove content from playlist.""" + if not can_edit_playlist(current_user, playlist_id): + flash('You do not have permission to edit this playlist.', 'danger') + return redirect(url_for('content.manage_playlist_content', playlist_id=playlist_id)) playlist = Playlist.query.get_or_404(playlist_id) try: @@ -454,6 +471,8 @@ def remove_content_from_playlist(playlist_id: int, content_id: int): @login_required def bulk_remove_from_playlist(playlist_id: int): """Remove multiple content items from playlist.""" + if not can_edit_playlist(current_user, playlist_id): + return jsonify({'success': False, 'message': 'Permission denied'}), 403 playlist = Playlist.query.get_or_404(playlist_id) try: @@ -496,6 +515,8 @@ def bulk_remove_from_playlist(playlist_id: int): @login_required def reorder_playlist_content(playlist_id: int): """Reorder content in playlist.""" + if not can_edit_playlist(current_user, playlist_id): + return jsonify({'success': False, 'message': 'Permission denied'}), 403 playlist = Playlist.query.get_or_404(playlist_id) try: diff --git a/digiserver-v2/app/blueprints/internal.py b/digiserver-v2/app/blueprints/internal.py index 5affeaf..0125099 100644 --- a/digiserver-v2/app/blueprints/internal.py +++ b/digiserver-v2/app/blueprints/internal.py @@ -40,8 +40,14 @@ def sync_user(): data = request.get_json(silent=True) or {} username = (data.get('username') or '').strip() - role_raw = (data.get('role') or 'user').strip() - role = 'admin' if role_raw == 'admin' else 'user' + role_raw = (data.get('role') or 'viewer').strip() + # Accept both old ('user') and new ('advanced'/'standard') role names from portal + if role_raw == 'admin': + role = 'admin' + elif role_raw in ('advanced', 'editor'): + role = 'editor' + else: + role = 'viewer' if not username: return jsonify({'error': 'username required'}), 400 diff --git a/digiserver-v2/app/models/__init__.py b/digiserver-v2/app/models/__init__.py index d8c012b..53b4bbf 100644 --- a/digiserver-v2/app/models/__init__.py +++ b/digiserver-v2/app/models/__init__.py @@ -9,6 +9,7 @@ from app.models.player_feedback import PlayerFeedback from app.models.player_edit import PlayerEdit from app.models.player_user import PlayerUser from app.models.https_config import HTTPSConfig +from app.models.playlist_permission import PlaylistPermission __all__ = [ 'User', diff --git a/digiserver-v2/app/models/playlist_permission.py b/digiserver-v2/app/models/playlist_permission.py new file mode 100644 index 0000000..ceebbbd --- /dev/null +++ b/digiserver-v2/app/models/playlist_permission.py @@ -0,0 +1,28 @@ +"""Per-user playlist edit permission for viewer-role accounts.""" +from datetime import datetime +from app.extensions import db + + +class PlaylistPermission(db.Model): + """ + Grants a viewer-role user edit access to a specific playlist. + + admin / editor users have implicit access to all playlists; + this table is only consulted for the 'viewer' role. + """ + __tablename__ = 'playlist_permissions' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='CASCADE'), nullable=False) + playlist_id = db.Column(db.Integer, db.ForeignKey('playlist.id', ondelete='CASCADE'), nullable=False) + granted_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + user = db.relationship('User', backref=db.backref('playlist_permissions', lazy='dynamic', cascade='all, delete-orphan')) + playlist = db.relationship('Playlist', backref=db.backref('permitted_users', lazy='dynamic', cascade='all, delete-orphan')) + + __table_args__ = ( + db.UniqueConstraint('user_id', 'playlist_id', name='uq_user_playlist_perm'), + ) + + def __repr__(self): + return f'' diff --git a/digiserver-v2/app/models/user.py b/digiserver-v2/app/models/user.py index b8b32b4..e6c70c3 100644 --- a/digiserver-v2/app/models/user.py +++ b/digiserver-v2/app/models/user.py @@ -24,7 +24,8 @@ class User(db.Model, UserMixin): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True) password = db.Column(db.String(120), nullable=False) - role = db.Column(db.String(20), nullable=False, default='user', index=True) + role = db.Column(db.String(20), nullable=False, default='viewer', index=True) + # Valid roles: 'admin' | 'editor' | 'viewer' theme = db.Column(db.String(20), default='light') created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) last_login = db.Column(db.DateTime, nullable=True) @@ -37,6 +38,16 @@ class User(db.Model, UserMixin): def is_admin(self) -> bool: """Check if user has admin role.""" return self.role == 'admin' + + @property + def is_editor(self) -> bool: + """True for admin and editor roles (can manage content).""" + return self.role in ('admin', 'editor') + + @property + def is_viewer(self) -> bool: + """True for all authenticated users (read access).""" + return True def update_last_login(self) -> None: """Update last login timestamp.""" diff --git a/digiserver-v2/app/templates/admin/playlist_permissions.html b/digiserver-v2/app/templates/admin/playlist_permissions.html new file mode 100644 index 0000000..97202b9 --- /dev/null +++ b/digiserver-v2/app/templates/admin/playlist_permissions.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}Playlist Permissions — {{ target_user.username }}{% endblock %} + +{% block content %} + + +
+

+ 🎬 Playlist Edit Permissions +

+

+ User: {{ target_user.username }} + + {{ target_user.role }} + +

+ + {% if target_user.role in ('admin', 'editor') %} +
+ ℹ️ This user already has {{ target_user.role }} access — they can edit all playlists automatically. + Playlist-level permissions are only relevant for viewer role users. +
+ {% else %} +

+ Select which playlists this viewer may add, remove and reorder content in. + They will still be able to view all playlists regardless of this setting. +

+ +
+ {% if playlists %} + + + + + + + + + + {% for playlist in playlists %} + + + + + + {% endfor %} + +
PlaylistDescriptionCan Edit?
+ {{ playlist.name }} +
+ {{ playlist.content_count }} items · {{ playlist.player_count }} player(s) +
+
+ {{ playlist.description or '—' }} + + +
+ +
+ + Cancel +
+ {% else %} +

No playlists created yet.

+ {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/digiserver-v2/app/templates/admin/user_management.html b/digiserver-v2/app/templates/admin/user_management.html index 5450bb1..fbc57f7 100644 --- a/digiserver-v2/app/templates/admin/user_management.html +++ b/digiserver-v2/app/templates/admin/user_management.html @@ -27,6 +27,7 @@ Role Created At Last Login + Permissions @@ -40,12 +41,22 @@ {% endif %} - + {{ user.role|capitalize }} {{ user.created_at | localtime if user.created_at else 'N/A' }} {{ user.last_login | localtime if user.last_login else 'Never' }} + + {% if user.role == 'viewer' %} + + 🎬 Playlists + + {% else %} + all access + {% endif %} + {% endfor %} {% else %} diff --git a/digiserver-v2/app/templates/base.html b/digiserver-v2/app/templates/base.html index f9a7d30..bef6e81 100644 --- a/digiserver-v2/app/templates/base.html +++ b/digiserver-v2/app/templates/base.html @@ -393,8 +393,17 @@ Playlists + {% if current_user.role in ('admin', 'editor') %} Admin - Logout ({{ current_user.username }}) + {% endif %} + + ⬡ Portal + + + Logout ({{ current_user.username }}) + {{ current_user.role }} + diff --git a/digiserver-v2/app/templates/content/content_list_new.html b/digiserver-v2/app/templates/content/content_list_new.html index 517022d..2b0f540 100644 --- a/digiserver-v2/app/templates/content/content_list_new.html +++ b/digiserver-v2/app/templates/content/content_list_new.html @@ -264,7 +264,8 @@
- + + {% if current_user.role in ('admin', 'editor') %}

@@ -300,6 +301,9 @@

+ {% endif %} + +
@@ -395,8 +399,9 @@
- ✏️ Manage + {% if playlist.id in editable_ids %}✏️ Manage{% else %}👁 View{% endif %} + {% if current_user.role in ('admin', 'editor') %}
+ {% endif %}
{% endfor %} diff --git a/digiserver-v2/app/templates/content/manage_playlist_content.html b/digiserver-v2/app/templates/content/manage_playlist_content.html index 7c1a1c5..2626a3f 100644 --- a/digiserver-v2/app/templates/content/manage_playlist_content.html +++ b/digiserver-v2/app/templates/content/manage_playlist_content.html @@ -274,19 +274,26 @@
-
+
← Back to Playlists + {% if not can_edit %} + + 👁 View-only — you don't have edit permission for this playlist + + {% endif %}
-

📋 Playlist Content (Drag to Reorder)

+

📋 Playlist Content{% if can_edit %} (Drag to Reorder){% endif %}

+ {% if can_edit %} + {% endif %}
{% if playlist_content %} @@ -308,11 +315,9 @@ {% for content in playlist_content %} - - - - - ⋮⋮ + + {% if can_edit %}{% endif %} + {% if can_edit %}⋮⋮{% endif %} {{ loop.index }} {% if content.content_type == 'weblink' %} @@ -384,6 +389,7 @@ {% endif %} + {% if can_edit %}
+ {% else %} + + {% endif %} + {% endfor %} @@ -406,6 +416,7 @@
+ {% if can_edit %}

➕ Add Content

@@ -457,6 +468,15 @@

All available content has been added to this playlist!

{% endif %} + + {% else %} + {# Viewer without edit permission on this playlist #} +
+
🔒
+

You have view-only access to this playlist.

+

Contact an administrator to request edit access.

+
+ {% endif %}
diff --git a/digiserver-v2/app/utils/access.py b/digiserver-v2/app/utils/access.py new file mode 100644 index 0000000..a89dd47 --- /dev/null +++ b/digiserver-v2/app/utils/access.py @@ -0,0 +1,64 @@ +""" +Shared role / access helpers for DigiServer blueprints. + +Import these instead of duplicating decorators in every blueprint. +""" +from functools import wraps +from flask import abort, flash, redirect, url_for +from flask_login import current_user + + +# ── Role-gate decorators ────────────────────────────────────────────────────── + +def editor_required(f): + """Allow admin and editor roles; redirect viewers with a flash message.""" + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_authenticated: + return redirect(url_for('auth.login')) + if current_user.role not in ('admin', 'editor'): + flash('You need editor or admin privileges to perform this action.', 'danger') + return redirect(url_for('main.dashboard')) + return f(*args, **kwargs) + return decorated + + +def admin_required(f): + """Allow admin role only.""" + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_authenticated: + return redirect(url_for('auth.login')) + if current_user.role != 'admin': + flash('Administrator access required.', 'danger') + return redirect(url_for('main.dashboard')) + return f(*args, **kwargs) + return decorated + + +# ── Playlist permission check ───────────────────────────────────────────────── + +def can_edit_playlist(user, playlist_id: int) -> bool: + """ + Return True if *user* is allowed to edit the given playlist. + + - admin / editor → always True + - viewer → True only when a PlaylistPermission row exists + """ + if user.role in ('admin', 'editor'): + return True + from app.models.playlist_permission import PlaylistPermission + return PlaylistPermission.query.filter_by( + user_id=user.id, playlist_id=playlist_id + ).first() is not None + + +def get_editable_playlist_ids(user) -> set: + """Return the set of playlist IDs the user may edit (used in list views).""" + if user.role in ('admin', 'editor'): + # Import here to avoid circular imports at module load time + from app.models.playlist import Playlist + return {p.id for p in Playlist.query.with_entities(Playlist.id).all()} + from app.models.playlist_permission import PlaylistPermission + rows = PlaylistPermission.query.filter_by(user_id=user.id).all() + return {r.playlist_id for r in rows} diff --git a/digiserver-v2/app/utils/portal_sso.py b/digiserver-v2/app/utils/portal_sso.py index d05b245..04ca405 100644 --- a/digiserver-v2/app/utils/portal_sso.py +++ b/digiserver-v2/app/utils/portal_sso.py @@ -3,7 +3,12 @@ Portal SSO middleware for DigiServer v2. When the umbrella nginx verifies the portal JWT it sets two headers: X-Auth-Username — the portal username - X-Auth-Role — 'admin' or 'user' + X-Auth-Role — 'admin' | 'advanced' | 'standard' + +Portal role → DigiServer local role mapping: + admin → admin (full access including user management) + advanced → editor (manage content/playlists, no user management) + standard → viewer (read-only) This before_request handler reads those headers and auto-logs in the corresponding local DigiServer user, creating them on first access if @@ -32,12 +37,21 @@ def init_portal_sso(app): login_user(user, remember=False) +def _portal_role_to_local(portal_role): + """Map a portal role string to the DigiServer local role.""" + if portal_role == 'admin': + return 'admin' + if portal_role == 'advanced': + return 'editor' + return 'viewer' # 'standard' or anything unknown + + def _get_or_create_user(username, role): from app.models.user import User from app.extensions import db, bcrypt try: - target_role = 'admin' if role == 'admin' else 'user' + target_role = _portal_role_to_local(role) user = User.query.filter_by(username=username).first() if not user: hashed_pw = bcrypt.generate_password_hash(secrets.token_hex(32)).decode('utf-8') diff --git a/portal/app/__init__.py b/portal/app/__init__.py index c298d0d..b469dd0 100644 --- a/portal/app/__init__.py +++ b/portal/app/__init__.py @@ -50,7 +50,7 @@ def _seed_admin(app): username=app.config['ADMIN_USERNAME'], email=app.config['ADMIN_EMAIL'], password_hash=generate_password_hash(app.config['ADMIN_PASSWORD']), - is_admin=True, + role='admin', is_active=True, ) db.session.add(admin) diff --git a/portal/app/models/user.py b/portal/app/models/user.py index 7c0edf1..8073ba3 100644 --- a/portal/app/models/user.py +++ b/portal/app/models/user.py @@ -3,6 +3,12 @@ from flask_login import UserMixin from app.extensions import db, login_manager +# Valid portal-level roles +PORTAL_ROLES = ('admin', 'advanced', 'standard') +# Valid per-app role overrides (same tier names) +APP_ROLES = ('admin', 'advanced', 'standard') + + class PortalUser(UserMixin, db.Model): __tablename__ = 'portal_users' @@ -10,7 +16,9 @@ class PortalUser(UserMixin, db.Model): username = db.Column(db.String(80), unique=True, nullable=False, index=True) email = db.Column(db.String(200), unique=True, nullable=False) password_hash = db.Column(db.String(256), nullable=False) - is_admin = db.Column(db.Boolean, default=False) + # role: 'admin' | 'advanced' | 'standard' + # (replaces the old is_admin Boolean — kept as property for template compat) + role = db.Column(db.String(20), nullable=False, default='standard') is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) last_login = db.Column(db.DateTime, nullable=True) @@ -18,6 +26,16 @@ class PortalUser(UserMixin, db.Model): app_accesses = db.relationship('AppAccess', backref='user', lazy='dynamic', cascade='all, delete-orphan') api_keys = db.relationship('ApiKey', backref='user', lazy='dynamic', cascade='all, delete-orphan') + @property + def is_admin(self): + """True when portal role is 'admin'. Kept for template/decorator backward compat.""" + return self.role == 'admin' + + @property + def is_advanced(self): + """True when portal role is 'admin' or 'advanced'.""" + return self.role in ('admin', 'advanced') + def get_accessible_apps(self): return [a.app_name for a in self.app_accesses.filter_by(is_active=True).all()] @@ -25,12 +43,12 @@ class PortalUser(UserMixin, db.Model): return self.app_accesses.filter_by(app_name=app_name, is_active=True).first() is not None def app_role(self, app_name): - """Return the per-app role override ('admin'|'user'), or None if not set.""" + """Return the per-app role override, or None if not set.""" access = self.app_accesses.filter_by(app_name=app_name, is_active=True).first() return access.app_role if access else None def __repr__(self): - return f'' + return f'' @login_manager.user_loader diff --git a/portal/app/routes/api.py b/portal/app/routes/api.py index f551292..981efeb 100644 --- a/portal/app/routes/api.py +++ b/portal/app/routes/api.py @@ -1,5 +1,6 @@ -from flask import Blueprint, request, make_response, current_app +from flask import Blueprint, request, make_response, current_app, jsonify import jwt +import secrets as _secrets bp = Blueprint('api', __name__, url_prefix='/api') @@ -70,3 +71,38 @@ def _app_from_uri(uri): if uri.startswith('/srvmonitor/'): return 'srvmonitor' return None + + +@bp.route('/internal/nv-users') +def nv_users_internal(): + """ + Internal endpoint for the NetworkView backend to fetch all portal users + that have NetworkView access, together with their assigned NV role. + Protected by X-Internal-Token header (shared INTERNAL_SYNC_SECRET). + """ + secret = current_app.config.get('INTERNAL_SYNC_SECRET', '') + provided = request.headers.get('X-Internal-Token', '') + if not secret or not _secrets.compare_digest(provided, provided and secret): + # constant-time compare — reject if secret mismatch or empty + if not secret or not provided or not _secrets.compare_digest(provided, secret): + return jsonify({'error': 'forbidden'}), 403 + + from app.models.user import PortalUser + from app.models.app_access import AppAccess + + users = PortalUser.query.filter_by(is_active=True).order_by(PortalUser.username).all() + result = [] + for u in users: + nv_access = AppAccess.query.filter_by( + user_id=u.id, app_name='networkview', is_active=True + ).first() + if nv_access is None: + continue # skip users who have no NV access at all + result.append({ + 'portal_id': str(u.id), + 'username': u.username, + 'email': u.email, + 'portal_role': u.role, + 'nv_role': nv_access.app_role or u.role, # per-app override or portal role + }) + return jsonify(result) diff --git a/portal/app/routes/auth.py b/portal/app/routes/auth.py index 89b5b00..3366e6c 100644 --- a/portal/app/routes/auth.py +++ b/portal/app/routes/auth.py @@ -19,7 +19,7 @@ def _issue_portal_cookie(user, response): 'sub': user.username, 'user_id': user.id, 'email': user.email, - 'role': 'admin' if user.is_admin else 'user', + 'role': user.role, 'apps': user.get_accessible_apps(), 'iss': 'enterprise-digital-platform', 'iat': now, @@ -74,3 +74,40 @@ def logout(): resp.delete_cookie(current_app.config['PORTAL_COOKIE_NAME'], path='/') flash('You have been signed out.', 'info') return resp + + +@bp.route('/portal-return') +def portal_return(): + """ + Re-establish a portal Flask-Login session from the JWT cookie. + + Sub-apps link here instead of '/' so the user is always properly + logged in to the portal even if the session cookie expired while + they were working in a sub-app. + """ + # Already logged in — just go to the dashboard + if current_user.is_authenticated: + return redirect(url_for('dashboard.index')) + + token = request.cookies.get(current_app.config['PORTAL_COOKIE_NAME']) + if not token: + return redirect(url_for('auth.login')) + + try: + payload = jwt.decode( + token, + current_app.config['PORTAL_JWT_SECRET'], + algorithms=['HS256'], + options={'require': ['exp', 'sub', 'user_id']}, + ) + except jwt.InvalidTokenError: + return redirect(url_for('auth.login')) + + user = PortalUser.query.get(payload.get('user_id')) + if not user or not user.is_active: + return redirect(url_for('auth.login')) + + login_user(user, remember=False) + resp = make_response(redirect(url_for('dashboard.index'))) + _issue_portal_cookie(user, resp) # refresh the JWT while we're here + return resp diff --git a/portal/app/routes/settings.py b/portal/app/routes/settings.py index 94276c6..b554edf 100644 --- a/portal/app/routes/settings.py +++ b/portal/app/routes/settings.py @@ -9,6 +9,9 @@ from app.models.api_key import ApiKey bp = Blueprint('settings', __name__, url_prefix='/settings') +VALID_APP_ROLES = {'admin', 'advanced', 'standard'} +VALID_PORTAL_ROLES = {'admin', 'advanced', 'standard'} + def _sync_user_to_app(app_id, username, role): """ @@ -76,7 +79,10 @@ def new_user(): username = request.form.get('username', '').strip() email = request.form.get('email', '').strip() password = request.form.get('password', '') - is_admin = request.form.get('is_admin') == 'on' + portal_role = request.form.get('portal_role', 'standard').strip() + if portal_role not in VALID_PORTAL_ROLES: + portal_role = 'standard' + is_admin = portal_role == 'admin' # kept for legacy checks if not username or not email or not password: flash('Username, email and password are required.', 'danger') @@ -90,7 +96,7 @@ def new_user(): username=username, email=email, password_hash=generate_password_hash(password), - is_admin=is_admin, + role=portal_role, is_active=True, ) db.session.add(user) @@ -99,7 +105,7 @@ def new_user(): for app in registered_apps: app_id = app['id'] role_val = request.form.get(f'role_{app_id}', 'none').strip() - if role_val in ('admin', 'user'): + if role_val in VALID_APP_ROLES: db.session.add(AppAccess(user_id=user.id, app_name=app_id, is_active=True, app_role=role_val)) @@ -109,7 +115,7 @@ def new_user(): for app in registered_apps: app_id = app['id'] role_val = request.form.get(f'role_{app_id}', 'none').strip() - if role_val in ('admin', 'user'): + if role_val in VALID_APP_ROLES: _sync_user_to_app(app_id, username, role_val) flash(f'User "{username}" created successfully.', 'success') @@ -127,10 +133,10 @@ def update_access(user_id): for app in registered_apps: app_id = app['id'] - # The UI sends role_ = 'admin' | 'user' | 'none' + # The UI sends role_ = 'admin' | 'advanced' | 'standard' | 'none' role_val = request.form.get(f'role_{app_id}', 'none').strip() - should_have = role_val in ('admin', 'user') - app_role = role_val if role_val in ('admin', 'user') else None + should_have = role_val in VALID_APP_ROLES + app_role = role_val if role_val in VALID_APP_ROLES else None existing = AppAccess.query.filter_by(user_id=user.id, app_name=app_id).first() if existing: @@ -146,7 +152,7 @@ def update_access(user_id): for app in registered_apps: app_id = app['id'] role_val = request.form.get(f'role_{app_id}', 'none').strip() - if role_val in ('admin', 'user'): + if role_val in VALID_APP_ROLES: _sync_user_to_app(app_id, user.username, role_val) flash(f'Access for "{user.username}" updated.', 'success') diff --git a/portal/app/static/css/portal.css b/portal/app/static/css/portal.css index be2d9bb..6fd2a07 100644 --- a/portal/app/static/css/portal.css +++ b/portal/app/static/css/portal.css @@ -105,12 +105,13 @@ code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.85em; } font-size: 0.7rem; padding: 2px 6px; border-radius: 20px; - background: rgba(56,139,253,0.2); - color: var(--accent-blue); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } +.role-tag--admin { background: rgba(56,139,253,0.2); color: var(--accent-blue); } +.role-tag--advanced { background: rgba(245,158,11,0.2); color: #f59e0b; } +.role-tag--standard { background: rgba(110,118,129,0.15); color: var(--text-secondary); } .btn-logout { color: var(--text-muted); @@ -376,6 +377,7 @@ code { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.85em; } letter-spacing: 0.04em; } .badge-admin { background: rgba(56,139,253,0.2); color: var(--accent-blue); } +.badge-advanced { background: rgba(245,158,11,0.2); color: #f59e0b; } .badge-user { background: rgba(110,118,129,0.2); color: var(--text-secondary); } .badge-active { background: rgba(63,185,80,0.2); color: var(--accent-green); } .badge-inactive { background: rgba(110,118,129,0.2); color: var(--text-muted); } diff --git a/portal/app/templates/base.html b/portal/app/templates/base.html index ab1e0e0..440f242 100644 --- a/portal/app/templates/base.html +++ b/portal/app/templates/base.html @@ -22,7 +22,13 @@
{{ current_user.username[0].upper() }} {{ current_user.username }} - {% if current_user.is_admin %}admin{% endif %} + {% if current_user.role == 'admin' %} + admin + {% elif current_user.role == 'advanced' %} + advanced + {% else %} + standard + {% endif %}
diff --git a/portal/app/templates/settings/index.html b/portal/app/templates/settings/index.html index c210a5a..3c07594 100644 --- a/portal/app/templates/settings/index.html +++ b/portal/app/templates/settings/index.html @@ -41,10 +41,12 @@ {{ user.username }} {{ user.email }} - {% if user.is_admin %} + {% if user.role == 'admin' %} admin + {% elif user.role == 'advanced' %} + advanced {% else %} - user + standard {% endif %} @@ -80,9 +82,13 @@ - +