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:
@@ -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)
|
||||
|
||||
@@ -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'<PortalUser {self.username}>'
|
||||
return f'<PortalUser {self.username} role={self.role}>'
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -22,7 +22,13 @@
|
||||
<div class="topbar-user">
|
||||
<span class="user-badge">{{ current_user.username[0].upper() }}</span>
|
||||
<span class="user-name">{{ current_user.username }}</span>
|
||||
{% if current_user.is_admin %}<span class="role-tag">admin</span>{% endif %}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<span class="role-tag role-tag--admin">admin</span>
|
||||
{% elif current_user.role == 'advanced' %}
|
||||
<span class="role-tag role-tag--advanced">advanced</span>
|
||||
{% else %}
|
||||
<span class="role-tag role-tag--standard">standard</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn-logout" title="Sign out">⏏</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -41,10 +41,12 @@
|
||||
<td><strong>{{ user.username }}</strong></td>
|
||||
<td class="text-muted">{{ user.email }}</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
{% if user.role == 'admin' %}
|
||||
<span class="badge badge-admin">admin</span>
|
||||
{% elif user.role == 'advanced' %}
|
||||
<span class="badge badge-advanced">advanced</span>
|
||||
{% else %}
|
||||
<span class="badge badge-user">user</span>
|
||||
<span class="badge badge-user">standard</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
@@ -80,9 +82,13 @@
|
||||
<option value="none" {% if not (cur_access and cur_access.is_active) %}selected{% endif %}>
|
||||
— no access
|
||||
</option>
|
||||
<option value="user"
|
||||
{% if cur_access and cur_access.is_active and (not cur_access.app_role or cur_access.app_role == 'user') %}selected{% endif %}>
|
||||
✓ user
|
||||
<option value="standard"
|
||||
{% if cur_access and cur_access.is_active and cur_access.app_role == 'standard' %}selected{% endif %}>
|
||||
👁 standard
|
||||
</option>
|
||||
<option value="advanced"
|
||||
{% if cur_access and cur_access.is_active and cur_access.app_role == 'advanced' %}selected{% endif %}>
|
||||
✏ advanced
|
||||
</option>
|
||||
<option value="admin"
|
||||
{% if cur_access and cur_access.is_active and cur_access.app_role == 'admin' %}selected{% endif %}>
|
||||
|
||||
@@ -34,13 +34,13 @@
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-input" required />
|
||||
</div>
|
||||
<div class="form-group form-group--check">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Portal Role</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="is_admin" />
|
||||
<span class="toggle-slider"></span>
|
||||
<span class="toggle-label">Platform administrator</span>
|
||||
</label>
|
||||
<select name="portal_role" class="form-select">
|
||||
<option value="standard">Standard User — read-only access in apps</option>
|
||||
<option value="advanced">Advanced User — create & edit content in apps</option>
|
||||
<option value="admin">Administrator — full access + user management</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,8 +61,9 @@
|
||||
<select name="role_{{ app['id'] }}" class="form-select app-role-new-select"
|
||||
data-color="{{ app.color }}">
|
||||
<option value="none">— No access</option>
|
||||
<option value="user">✓ User</option>
|
||||
<option value="admin">★ Admin</option>
|
||||
<option value="standard">👁 Standard — read-only</option>
|
||||
<option value="advanced">✏ Advanced — create & edit</option>
|
||||
<option value="admin">★ Admin — full access</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Migration: Add 3-tier role system to the portal DB.
|
||||
|
||||
Replaces the old binary is_admin boolean with a role column
|
||||
('admin' | 'advanced' | 'standard') on portal_users, and updates
|
||||
existing app_access.app_role values from the old 'user' string
|
||||
to the new 'standard' string.
|
||||
|
||||
Run once against your existing portal.db:
|
||||
cd portal
|
||||
python migrate_roles.py
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = os.environ.get(
|
||||
'DATABASE_URL',
|
||||
os.path.join(os.path.dirname(__file__), 'data', 'portal.db'),
|
||||
).replace('sqlite:///', '')
|
||||
|
||||
if not os.path.exists(DB_PATH):
|
||||
# Try the instance folder as a fallback
|
||||
DB_PATH = os.path.join(os.path.dirname(__file__), 'instance', 'portal.db')
|
||||
|
||||
print(f'Migrating: {DB_PATH}')
|
||||
|
||||
con = sqlite3.connect(DB_PATH)
|
||||
cur = con.cursor()
|
||||
|
||||
# ── 1. portal_users: add role column if missing ────────────────────────────────
|
||||
cols = {row[1] for row in cur.execute("PRAGMA table_info(portal_users)")}
|
||||
|
||||
if 'role' not in cols:
|
||||
print(' Adding portal_users.role column …')
|
||||
cur.execute("ALTER TABLE portal_users ADD COLUMN role TEXT NOT NULL DEFAULT 'standard'")
|
||||
# Populate from the old is_admin boolean
|
||||
if 'is_admin' in cols:
|
||||
cur.execute("""
|
||||
UPDATE portal_users
|
||||
SET role = CASE WHEN is_admin = 1 THEN 'admin' ELSE 'standard' END
|
||||
""")
|
||||
print(' Populated role from is_admin.')
|
||||
con.commit()
|
||||
else:
|
||||
print(' portal_users.role already exists — skipping add.')
|
||||
|
||||
# ── 2. app_access: rename old 'user' role value to 'standard' ─────────────────
|
||||
updated = cur.execute(
|
||||
"UPDATE app_access SET app_role = 'standard' WHERE app_role = 'user'"
|
||||
).rowcount
|
||||
if updated:
|
||||
print(f' Updated {updated} app_access rows: user → standard')
|
||||
con.commit()
|
||||
|
||||
print('Migration complete.')
|
||||
con.close()
|
||||
Reference in New Issue
Block a user