diff --git a/IT_asset_management/app/__init__.py b/IT_asset_management/app/__init__.py index 0d89521..8b6a0ad 100644 --- a/IT_asset_management/app/__init__.py +++ b/IT_asset_management/app/__init__.py @@ -38,6 +38,7 @@ def create_app(config_name='default'): from app.routes.audit import bp as audit_bp from app.routes.settings import bp as settings_bp from app.routes.doc_templates import bp as doc_templates_bp + from app.routes.internal import bp as internal_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -48,6 +49,7 @@ def create_app(config_name='default'): app.register_blueprint(audit_bp) app.register_blueprint(settings_bp) app.register_blueprint(doc_templates_bp) + app.register_blueprint(internal_bp) # Inject common template variables from datetime import datetime, date diff --git a/IT_asset_management/app/routes/__init__.py b/IT_asset_management/app/routes/__init__.py index 24dbc8d..4ced1c7 100644 --- a/IT_asset_management/app/routes/__init__.py +++ b/IT_asset_management/app/routes/__init__.py @@ -6,8 +6,9 @@ from app.routes.assignments import bp as assignments_bp from app.routes.paperwork import bp as paperwork_bp from app.routes.audit import bp as audit_bp from app.routes.settings import bp as settings_bp +from app.routes.internal import bp as internal_bp __all__ = [ 'auth_bp', 'dashboard_bp', 'users_bp', 'assets_bp', - 'assignments_bp', 'paperwork_bp', 'audit_bp', 'settings_bp', + 'assignments_bp', 'paperwork_bp', 'audit_bp', 'settings_bp', 'internal_bp', ] diff --git a/IT_asset_management/app/routes/assets.py b/IT_asset_management/app/routes/assets.py index 2a62418..5e1c29d 100644 --- a/IT_asset_management/app/routes/assets.py +++ b/IT_asset_management/app/routes/assets.py @@ -4,6 +4,7 @@ from flask import (Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify) from flask_login import login_required, current_user from app.extensions import db +from app.utils.decorators import editor_required from app.models.asset import Asset, ASSET_TYPES, ASSET_STATUSES from app.models.audit_log import AuditLog from app.models.compliance_check import ComplianceCheck @@ -82,6 +83,7 @@ def index(): # ------------------------------------------------------------------ @bp.route('/new', methods=['GET', 'POST']) @login_required +@editor_required def create(): if request.method == 'POST': sn = request.form.get('serial_number', '').strip() @@ -204,6 +206,7 @@ def detail(asset_id): # ------------------------------------------------------------------ @bp.route('//edit', methods=['GET', 'POST']) @login_required +@editor_required def edit(asset_id): asset = Asset.query.get_or_404(asset_id) @@ -267,6 +270,7 @@ _COMPLIANCE_FIELDS = { @bp.route('//compliance', methods=['POST']) @login_required +@editor_required def update_compliance(asset_id): asset = Asset.query.get_or_404(asset_id) diff --git a/IT_asset_management/app/routes/assignments.py b/IT_asset_management/app/routes/assignments.py index c49bfe3..98cc1e8 100644 --- a/IT_asset_management/app/routes/assignments.py +++ b/IT_asset_management/app/routes/assignments.py @@ -4,6 +4,7 @@ from flask import (Blueprint, render_template, redirect, url_for, flash, request, current_app) from flask_login import login_required, current_user from app.extensions import db +from app.utils.decorators import editor_required from app.models.assignment import Assignment from app.models.asset import Asset from app.models.user import User @@ -46,6 +47,7 @@ def index(): @bp.route('/new', methods=['GET', 'POST']) @login_required +@editor_required def create(): # Pre-fill from query params (used from asset / user detail pages) preselect_asset_id = request.args.get('asset_id', type=int) @@ -110,6 +112,7 @@ def create(): @bp.route('//return', methods=['POST']) @login_required +@editor_required def return_asset(assignment_id): assignment = Assignment.query.get_or_404(assignment_id) diff --git a/IT_asset_management/app/routes/auth.py b/IT_asset_management/app/routes/auth.py index 9750086..ced41f1 100644 --- a/IT_asset_management/app/routes/auth.py +++ b/IT_asset_management/app/routes/auth.py @@ -1,18 +1,36 @@ -from flask import Blueprint, redirect, url_for, current_app -from flask_login import logout_user, login_required +from datetime import datetime +from flask import Blueprint, redirect, url_for, render_template, request, flash +from flask_login import login_user, logout_user, login_required, current_user +from app.extensions import db +from app.models.admin_user import AdminUser bp = Blueprint('auth', __name__) @bp.route('/login', methods=['GET', 'POST']) def login(): - # Authentication is handled by the EDP portal via nginx SSO. - # Redirect any direct login attempts to the portal. - return redirect(current_app.config.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login')) + # Portal SSO (nginx headers) handles production login automatically. + # This route handles direct / standalone access. + if current_user.is_authenticated: + return redirect(url_for('dashboard.index')) + + if request.method == 'POST': + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + user = AdminUser.query.filter_by(username=username, is_active=True).first() + if user and user.check_password(password): + login_user(user, remember=False) + user.last_login = datetime.utcnow() + db.session.commit() + next_page = request.args.get('next') or url_for('dashboard.index') + return redirect(next_page) + flash('Invalid username or password.', 'danger') + + return render_template('auth/login.html') @bp.route('/logout') @login_required def logout(): logout_user() - return redirect(current_app.config.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout')) + return redirect(url_for('auth.login')) diff --git a/IT_asset_management/app/routes/doc_templates.py b/IT_asset_management/app/routes/doc_templates.py index 99d5db8..8d766b1 100644 --- a/IT_asset_management/app/routes/doc_templates.py +++ b/IT_asset_management/app/routes/doc_templates.py @@ -4,6 +4,7 @@ from flask import (Blueprint, render_template, redirect, url_for, flash, request, current_app, send_from_directory, jsonify) from flask_login import login_required, current_user from werkzeug.utils import secure_filename +from app.utils.decorators import editor_required from app.extensions import db from app.models.document_template import DocumentTemplate from app.models.paperwork import DOC_TYPES @@ -54,6 +55,7 @@ def index(): # ------------------------------------------------------------------ @bp.route('/upload', methods=['GET', 'POST']) @login_required +@editor_required def upload(): if request.method == 'POST': name = request.form.get('name', '').strip() @@ -134,6 +136,7 @@ def download(tpl_id): # ------------------------------------------------------------------ @bp.route('//rescan', methods=['POST']) @login_required +@editor_required def rescan(tpl_id): tpl = DocumentTemplate.query.get_or_404(tpl_id) folder = _template_folder(current_app) @@ -153,6 +156,7 @@ def rescan(tpl_id): # ------------------------------------------------------------------ @bp.route('//edit', methods=['GET', 'POST']) @login_required +@editor_required def edit(tpl_id): tpl = DocumentTemplate.query.get_or_404(tpl_id) if request.method == 'POST': @@ -170,6 +174,7 @@ def edit(tpl_id): # ------------------------------------------------------------------ @bp.route('//delete', methods=['POST']) @login_required +@editor_required def delete(tpl_id): tpl = DocumentTemplate.query.get_or_404(tpl_id) # Check if any documents were generated from this template diff --git a/IT_asset_management/app/routes/internal.py b/IT_asset_management/app/routes/internal.py new file mode 100644 index 0000000..318e968 --- /dev/null +++ b/IT_asset_management/app/routes/internal.py @@ -0,0 +1,73 @@ +""" +Internal routes — called by the EDP portal (not by browsers). + +POST /internal/sync-user + Called by the portal whenever it grants or updates a user's access to the + itassets app. Ensures the local AdminUser record exists and has the correct + role before the user ever logs in for the first time. + + Body (JSON): { "username": "alice", "role": "advanced" } + Header: X-Internal-Token: + + Portal roles are mapped to local roles: + admin → admin + advanced → editor + standard → readonly +""" +import secrets +from flask import Blueprint, request, jsonify, current_app +from app.extensions import db +from app.models.admin_user import AdminUser +from app.utils.portal_sso import _portal_role_to_local + +bp = Blueprint('internal', __name__, url_prefix='/internal') + + +def _check_token(): + expected = current_app.config.get('INTERNAL_SYNC_SECRET', '') + provided = request.headers.get('X-Internal-Token', '') + # Use hmac.compare_digest equivalent via secrets.compare_digest + if not expected or not secrets.compare_digest(expected, provided): + return False + return True + + +@bp.route('/sync-user', methods=['POST']) +def sync_user(): + if not _check_token(): + return jsonify({'error': 'Unauthorized'}), 401 + + data = request.get_json(silent=True) or {} + username = (data.get('username') or '').strip() + portal_role = (data.get('role') or 'standard').strip() + + if not username: + return jsonify({'error': 'username is required'}), 400 + + local_role = _portal_role_to_local(portal_role) + + try: + user = AdminUser.query.filter_by(username=username).first() + if user: + if user.role != local_role: + user.role = local_role + db.session.commit() + status = 'updated' + else: + user = AdminUser( + username=username, + email=f'{username}@portal.local', + full_name=username, + role=local_role, + is_active=True, + ) + user.set_password(secrets.token_hex(32)) # random unusable password + db.session.add(user) + db.session.commit() + status = 'created' + + return jsonify({'status': status, 'username': username, 'role': local_role}), 200 + + except Exception as exc: + current_app.logger.error('sync-user failed: %s', exc) + return jsonify({'error': str(exc)}), 500 diff --git a/IT_asset_management/app/routes/paperwork.py b/IT_asset_management/app/routes/paperwork.py index 694c9af..d2c267e 100644 --- a/IT_asset_management/app/routes/paperwork.py +++ b/IT_asset_management/app/routes/paperwork.py @@ -5,6 +5,7 @@ from flask import (Blueprint, render_template, redirect, url_for, flash, request, current_app, send_from_directory, abort, jsonify) from flask_login import login_required, current_user from app.extensions import db +from app.utils.decorators import editor_required from app.models.paperwork import Paperwork, DOC_TYPES from app.models.document_template import DocumentTemplate from app.models.user import User @@ -54,6 +55,7 @@ def index(): @bp.route('/new', methods=['GET', 'POST']) @login_required +@editor_required def create(): preselect_user_id = request.args.get('user_id', type=int) preselect_asset_id = request.args.get('asset_id', type=int) @@ -177,6 +179,7 @@ def download_docx(doc_id): @bp.route('//regenerate', methods=['POST']) @login_required +@editor_required def regenerate(doc_id): doc = Paperwork.query.get_or_404(doc_id) app = current_app._get_current_object() @@ -204,6 +207,7 @@ def regenerate(doc_id): @bp.route('//sign', methods=['POST']) @login_required +@editor_required def sign(doc_id): """Record a signature on a document.""" doc = Paperwork.query.get_or_404(doc_id) @@ -234,6 +238,7 @@ def sign(doc_id): @bp.route('//unsign', methods=['POST']) @login_required +@editor_required def unsign(doc_id): doc = Paperwork.query.get_or_404(doc_id) doc.signed_at = None diff --git a/IT_asset_management/app/routes/settings.py b/IT_asset_management/app/routes/settings.py index df59442..c5e31e2 100644 --- a/IT_asset_management/app/routes/settings.py +++ b/IT_asset_management/app/routes/settings.py @@ -2,6 +2,7 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request, from flask_login import login_required, current_user from app.extensions import db from app.models.admin_user import AdminUser +from app.utils.decorators import admin_required bp = Blueprint('settings', __name__, url_prefix='/settings') @@ -15,12 +16,16 @@ def index(): @bp.route('/admin/new', methods=['POST']) @login_required +@admin_required def create_admin(): username = request.form.get('username', '').strip() email = request.form.get('email', '').strip() full_name = request.form.get('full_name', '').strip() password = request.form.get('password', '') - role = request.form.get('role', 'admin') + role = request.form.get('role', 'readonly') + + if role not in ('admin', 'editor', 'readonly'): + role = 'readonly' if not username or not email or not password: flash('Username, email and password are required.', 'danger') @@ -34,12 +39,13 @@ def create_admin(): admin.set_password(password) db.session.add(admin) db.session.commit() - flash(f'Admin user "{username}" created.', 'success') + flash(f'User "{username}" created with role "{role}".', 'success') return redirect(url_for('settings.index')) @bp.route('/admin//toggle', methods=['POST']) @login_required +@admin_required def toggle_admin(admin_id): admin = AdminUser.query.get_or_404(admin_id) if admin.id == current_user.id: @@ -48,5 +54,53 @@ def toggle_admin(admin_id): admin.is_active = not admin.is_active db.session.commit() status = 'activated' if admin.is_active else 'deactivated' - flash(f'Admin "{admin.username}" {status}.', 'success') + flash(f'User "{admin.username}" {status}.', 'success') + return redirect(url_for('settings.index')) + + +@bp.route('/admin//change-role', methods=['POST']) +@login_required +@admin_required +def change_role(admin_id): + admin = AdminUser.query.get_or_404(admin_id) + if admin.id == current_user.id: + flash('You cannot change your own role.', 'danger') + return redirect(url_for('settings.index')) + role = request.form.get('role', 'readonly') + if role not in ('admin', 'editor', 'readonly'): + flash('Invalid role.', 'danger') + return redirect(url_for('settings.index')) + admin.role = role + db.session.commit() + flash(f'Role for "{admin.username}" updated to "{role}".', 'success') + return redirect(url_for('settings.index')) + + +@bp.route('/admin//reset-password', methods=['POST']) +@login_required +@admin_required +def reset_password(admin_id): + admin = AdminUser.query.get_or_404(admin_id) + new_password = request.form.get('new_password', '') + if len(new_password) < 8: + flash('Password must be at least 8 characters.', 'danger') + return redirect(url_for('settings.index')) + admin.set_password(new_password) + db.session.commit() + flash(f'Password for "{admin.username}" has been reset.', 'success') + return redirect(url_for('settings.index')) + + +@bp.route('/admin//delete', methods=['POST']) +@login_required +@admin_required +def delete_admin(admin_id): + admin = AdminUser.query.get_or_404(admin_id) + if admin.id == current_user.id: + flash('You cannot delete your own account.', 'danger') + return redirect(url_for('settings.index')) + username = admin.username + db.session.delete(admin) + db.session.commit() + flash(f'User "{username}" deleted.', 'success') return redirect(url_for('settings.index')) diff --git a/IT_asset_management/app/routes/users.py b/IT_asset_management/app/routes/users.py index 3a838e6..6670115 100644 --- a/IT_asset_management/app/routes/users.py +++ b/IT_asset_management/app/routes/users.py @@ -4,6 +4,7 @@ from flask import (Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify) from flask_login import login_required, current_user from app.extensions import db +from app.utils.decorators import editor_required from app.models.user import User from app.models.audit_log import AuditLog from app.services.csv_service import parse_users_csv @@ -63,6 +64,7 @@ def index(): # ------------------------------------------------------------------ @bp.route('/new', methods=['GET', 'POST']) @login_required +@editor_required def create(): if request.method == 'POST': windows_id = request.form.get('windows_id', '').strip() @@ -113,6 +115,7 @@ def detail(user_id): # ------------------------------------------------------------------ @bp.route('//edit', methods=['GET', 'POST']) @login_required +@editor_required def edit(user_id): user = User.query.get_or_404(user_id) @@ -148,6 +151,7 @@ def edit(user_id): # ------------------------------------------------------------------ @bp.route('//mask', methods=['POST']) @login_required +@editor_required def mask(user_id): user = User.query.get_or_404(user_id) @@ -191,6 +195,7 @@ def import_page(): # ------------------------------------------------------------------ @bp.route('/import/csv', methods=['POST']) @login_required +@editor_required def import_csv(): file = request.files.get('csv_file') if not file or not file.filename.endswith('.csv'): @@ -249,6 +254,7 @@ def import_csv(): # ------------------------------------------------------------------ @bp.route('/import/ldap', methods=['POST']) @login_required +@editor_required def import_ldap(): if not current_app.config.get('LDAP_SERVER'): flash('LDAP server is not configured. Update Settings first.', 'danger') diff --git a/IT_asset_management/app/templates/base.html b/IT_asset_management/app/templates/base.html index 03e1cf7..f418508 100644 --- a/IT_asset_management/app/templates/base.html +++ b/IT_asset_management/app/templates/base.html @@ -127,40 +127,48 @@ class="nav-link {% if request.blueprint == 'users' %}active{% endif %}"> Users + {% if current_user.is_editor %} Import Users + {% endif %} Assets + {% if current_user.is_editor %} Add Asset + {% endif %} Assignments + {% if current_user.is_editor %} Assign Asset + {% endif %} Paperwork + {% if current_user.is_editor %} New Document + {% endif %} Templates @@ -179,6 +187,9 @@ +{% if current_user.is_editor %}
@@ -123,6 +124,7 @@
+{% endif %}
diff --git a/IT_asset_management/app/templates/settings/index.html b/IT_asset_management/app/templates/settings/index.html index 1b9ff13..c74dbff 100644 --- a/IT_asset_management/app/templates/settings/index.html +++ b/IT_asset_management/app/templates/settings/index.html @@ -11,50 +11,79 @@
- +
- Admin Users + Application Users
- + + {% if current_user.is_admin %}{% endif %} + {% for a in admins %} - + - + - + {% endif %} {% endfor %}
UsernameFull NameEmailRoleLast LoginActive
UsernameFull NameEmailRoleLast LoginActive
{{ a.username }}{{ a.username }}{% if a.id == current_user.id %} you{% endif %} {{ a.full_name or '—' }} {{ a.email }}{{ a.role }} + + {{ a.role }} + + {{ a.last_login.strftime('%d/%m/%Y') if a.last_login else '—' }} - {% if a.is_active %} - Active - {% else %} - Inactive - {% endif %} + {% if a.is_active %}Active + {% else %}Inactive{% endif %} + {% if current_user.is_admin %} + {% if a.id != current_user.id %} -
- +
+ +
+ +
+ + - + +
+ +
+ {% endif %}
- + + {% if current_user.is_admin %} @@ -100,9 +142,11 @@
+ {% if current_user.is_editor %} Go to Import / Sync + {% endif %}
@@ -123,4 +167,31 @@
+ + +{% if current_user.is_admin %} +{% for a in admins %} +{% if a.id != current_user.id %} + +{% endif %} +{% endfor %} +{% endif %} {% endblock %} diff --git a/IT_asset_management/app/templates/users/index.html b/IT_asset_management/app/templates/users/index.html index 39bf9a1..c4095fc 100644 --- a/IT_asset_management/app/templates/users/index.html +++ b/IT_asset_management/app/templates/users/index.html @@ -8,6 +8,7 @@ {% block content %} diff --git a/IT_asset_management/app/utils/decorators.py b/IT_asset_management/app/utils/decorators.py new file mode 100644 index 0000000..1b483ac --- /dev/null +++ b/IT_asset_management/app/utils/decorators.py @@ -0,0 +1,29 @@ +from functools import wraps +from flask import flash, redirect, url_for +from flask_login import current_user + + +def editor_required(f): + """Allow only admin and editor roles. Readonly users are redirected.""" + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_authenticated: + return redirect(url_for('auth.login')) + if not current_user.is_editor: + flash('You do not have permission to perform this action.', 'danger') + return redirect(url_for('dashboard.index')) + return f(*args, **kwargs) + return decorated + + +def admin_required(f): + """Allow only admin role.""" + @wraps(f) + def decorated(*args, **kwargs): + if not current_user.is_authenticated: + return redirect(url_for('auth.login')) + if not current_user.is_admin: + flash('Administrator access is required.', 'danger') + return redirect(url_for('dashboard.index')) + return f(*args, **kwargs) + return decorated diff --git a/IT_asset_management/config.py b/IT_asset_management/config.py index e3d1dc8..49a6b73 100644 --- a/IT_asset_management/config.py +++ b/IT_asset_management/config.py @@ -54,6 +54,9 @@ class Config: PORTAL_LOGIN_URL = os.environ.get('PORTAL_LOGIN_URL', 'http://localhost:8080/login') PORTAL_LOGOUT_URL = os.environ.get('PORTAL_LOGOUT_URL', 'http://localhost:8080/logout') + # Internal service-to-service sync secret — must match portal's INTERNAL_SYNC_SECRET + INTERNAL_SYNC_SECRET = os.environ.get('INTERNAL_SYNC_SECRET', 'change-this-internal-secret') + # Pagination ITEMS_PER_PAGE = int(os.environ.get('ITEMS_PER_PAGE', 25)) diff --git a/portal/config.py b/portal/config.py index 1f777e2..2769041 100644 --- a/portal/config.py +++ b/portal/config.py @@ -46,6 +46,8 @@ class Config: 'icon': '💼', 'url': '/itassets/', 'color': '#f59e0b', + # Internal URL for portal→app user sync (not through nginx) + 'internal_url': os.environ.get('ITASSETS_INTERNAL_URL', 'http://localhost:5003'), }, { 'id': 'srvmonitor',