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:
ske087
2026-07-07 00:08:46 +03:00
parent 5762dd420d
commit 1f6217d347
41 changed files with 1028 additions and 153 deletions
+1 -1
View File
@@ -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)
+21 -3
View File
@@ -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
+37 -1
View File
@@ -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)
+38 -1
View File
@@ -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
+14 -8
View File
@@ -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')
+4 -2
View File
@@ -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); }
+7 -1
View File
@@ -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>
+11 -5
View File
@@ -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 %}>
+9 -8
View File
@@ -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 &amp; 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 &amp; edit</option>
<option value="admin">★ Admin — full access</option>
</select>
</div>
{% endfor %}
+56
View File
@@ -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()