feat: 3-tier role system across all platform apps
Portal: - Replace is_admin boolean with role column (admin/advanced/standard) - Settings UI: 3-tier portal role select + per-app role dropdowns - /portal-return endpoint: re-establishes session from JWT for sub-app back-links - /api/internal/nv-users: internal endpoint for NetworkView user sync - portal/migrate_roles.py: one-time DB migration script - Role badges (admin/advanced/standard) in topbar and settings table DigiServer: - Add editor and viewer roles (portal advanced->editor, standard->viewer) - PlaylistPermission model: grant viewer users edit access to specific playlists - app/utils/access.py: shared editor_required, admin_required, can_edit_playlist helpers - Content routes: editor_required on create/delete, per-playlist permission check on mutations - Admin: playlist_permissions route + template to manage viewer playlist grants - Base template: hide Admin nav for viewers, Portal button (⬡) returns to portal - content_list_new: hide create/delete for viewers; Manage vs View button per permission - manage_playlist_content: view-only mode when user lacks edit permission NetworkView: - backend/src/middleware/rbac.js: requireRole + requireWriteAccess helpers - site_permissions table: one site per advanced user - All mutating routes guarded (admin=all, advanced=assigned site, viewer=read-only) - Portal SSO auto-upsert: user row synced from X-Auth-Role on every request - GET /api/users: merges portal users list with local NV data (all 4 portal users visible) - GET/PUT /api/users/:id/site-permission: assign site to advanced user - Settings Users tab: role badges, site dropdown for advanced, (portal only) indicator - Sidebar: ⬡ Portal button between Settings and Logout - Frontend build: VITE_API_BASE=/networkview/api now set in start-dev.sh IT Assets / Server Monitor: - portal_sso.py updated: map advanced->editor/viewer, standard->readonly - AdminUser model: add editor role + is_editor property
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_<app_id> = 'admin' | 'user' | 'none'
|
||||
# The UI sends role_<app_id> = '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')
|
||||
|
||||
Reference in New Issue
Block a user