diff --git a/.github/prompts/prepare-for-pull-request.prompt.md b/.github/prompts/prepare-for-pull-request.prompt.md new file mode 100644 index 000000000..22a9fc3a1 --- /dev/null +++ b/.github/prompts/prepare-for-pull-request.prompt.md @@ -0,0 +1,161 @@ +--- +name: "Prepare for Pull Request" +description: "Use when the user says: Prepare for a pull request, PR prep, ready this branch for PR, validate before PR, or create a PR to Development. Runs SimpleChat branch sync and validation workflow before optionally pushing or creating a GitHub pull request." +argument-hint: "Optional: include merge Development, rebase Development, push, create draft PR, or create PR" +agent: "agent" +--- + +# Prepare SimpleChat for a Pull Request + +You are preparing the current SimpleChat branch for a GitHub pull request into `Development`. + +The default outcome is a verified PR-readiness report. You may push the branch or create the pull request only after explicitly asking the user to proceed and receiving confirmation. If the user already asked to push or create the PR, still pause at the final confirmation gate before doing it. + +## Repository Policy + +- Work in the SimpleChat repository. +- Target contributor PRs to `Development`, not `main` or `Staging`. +- Treat `Development` and `Staging` as case-sensitive branch names. +- Prefer the remote named `upstream` when it exists and has `Development`; otherwise use `origin/Development`. +- Never use destructive Git commands such as `git reset --hard`, `git checkout -- `, or force push unless the user explicitly asks and understands the consequence. +- Do not revert unrelated user changes. +- Do not commit, push, or create a PR without a user confirmation step. +- If secrets, `.env` files, session tokens, storage keys, connection strings, or generated local artifacts appear in the changed file set, stop and ask before proceeding. + +## Initial Discovery + +Run read-only checks first and summarize what you find: + +1. Confirm the repo root and current branch. +2. Show concise working tree status, including untracked files. +3. Show remotes and identify the remote target branch for `Development`. +4. Fetch the target branch: `git fetch Development`. +5. Determine whether the current branch contains the latest target branch: + - Up to date when `/Development` is an ancestor of `HEAD`. + - Behind or diverged when it is not. +6. Determine changed files relative to `/Development` and include both committed and uncommitted local changes. + +If the current branch is `Development`, `Staging`, or `main`, stop and ask whether to create or switch to a feature branch before continuing. + +## Development Sync Gate + +Before reporting PR readiness or creating a PR, ensure the current branch includes the latest `Development` changes. + +Default behavior: + +- Fetch `Development` and report whether the branch is up to date. +- If the branch is behind or diverged, stop and ask how to integrate the changes. +- Do not merge or rebase automatically. + +Supported opt-in behaviors: + +- If the user asks to `merge Development`, run a normal merge from `/Development` after confirming. +- If the user asks to `rebase Development` or `rebase onto Development`, rebase onto `/Development` after confirming. +- If the user asks only to check readiness, do not integrate. Explain that PR readiness is blocked until the branch includes the latest target branch. + +After any merge or rebase, recompute changed files and rerun all applicable validation. + +## Conflict Handling + +If a merge or rebase produces conflicts, do not ask the user to manually resolve files. Resolve conflicts interactively with the user while you do the editing. + +For each conflict: + +1. Identify the conflicted file and the affected feature or behavior. +2. Explain the two sides clearly: + - `ours`: what the user's current branch changed. + - `theirs`: what `Development` changed. +3. Explain the likely impact of choosing either side or combining them. +4. Recommend a resolution when there is enough context, but ask the user how to proceed. +5. Apply the selected resolution yourself. +6. Continue until there are no conflict markers and `git status` shows no unmerged paths. +7. Continue the merge or rebase with non-interactive Git commands. + +If the user cancels conflict resolution, report the current Git state and the safest next step. Do not abandon a merge or rebase unless the user explicitly asks. + +## Local Validation + +Use the repo's existing workflow files, scripts, and instructions as the source of truth. Run the smallest reliable set that matches the changed files, then broaden only when risk calls for it. + +Always run: + +- `git diff --check` for changed files. +- A Python syntax compile check for changed Python files, and at minimum the Python files under `application/single_app` that GitHub compiles. +- Any new or changed test files directly. + +When Python route files changed: + +- Run `python scripts/check_swagger_routes.py `. +- Verify every Flask route has `@swagger_route(security=get_auth_security())` immediately after `@app.route(...)`. + +When JavaScript, HTML, or Python browser-rendering surfaces changed: + +- Run `python scripts/check_xss_sinks.py` against changed `application/**/*.js`, `application/**/*.html`, and `application/**/*.py` files. +- If validating only committed changes, prefer changed-line mode: + `python scripts/check_xss_sinks.py --base-sha /Development --head-sha HEAD `. +- If the working tree has uncommitted changes, use `--full-file` for those files because they are not represented by `HEAD`. + +When Python authorization-sensitive surfaces changed: + +- Run `python scripts/check_broken_access_control.py` against changed `application/**/*.py` files. +- If validating only committed changes, prefer changed-line mode: + `python scripts/check_broken_access_control.py --base-sha /Development --head-sha HEAD `. +- If the working tree has uncommitted changes, use `--full-file` for those files. + +When HTML, CSS, or JavaScript changes affect UI behavior: + +- Run relevant `ui_tests/` tests when the local app and authentication state are available. +- If UI tests cannot be run locally, say why and include the gap in the final PR-readiness report. + +When bug fixes, new features, route changes, API changes, security changes, or UI workflow changes are present: + +- Check for matching functional tests under `functional_tests/`. +- Run relevant functional tests with pytest or directly with Python, matching existing test style. +- If no relevant test exists, call this out and recommend the missing coverage. + +When application code changed: + +- Verify `application/single_app/config.py` has an appropriate patch version bump unless the change is docs-only. +- Verify fix or feature documentation exists when required by the repo instructions. +- Check whether `docs/explanation/release_notes.md` should be updated. If release notes are needed and missing, update them under the current `config.py` version as part of PR readiness unless the user explicitly requested report-only behavior. +- If the current `config.py` version does not have a release notes section yet, create a new section at the top of the release notes before adding the entry. + +## PR Readiness Review + +Before asking to push or create a PR, provide a concise report with: + +- Current branch and target branch. +- Whether the branch includes the latest `Development`. +- Changed-file summary grouped by area. +- Validation commands run and pass/fail status. +- Tests run and any skipped tests with reasons. +- Version, documentation, and release notes status. +- Security review notes for secrets, XSS, broken access control, settings sanitization, and route decorators. +- Remaining risks or manual follow-up. + +If validation fails, fix issues that are clearly in scope and rerun the affected checks. If a failure is unrelated or needs a product decision, stop and explain the blocker. + +## Optional Push and PR Creation + +Only after the readiness report is clean or the user accepts the noted risks, ask whether to proceed with push and PR creation. + +If uncommitted changes remain: + +- Do not push or create a PR yet. +- Ask whether the user wants you to stage and commit the PR-ready changes. +- Propose a concise commit message based on the change summary. + +If the user confirms push: + +- Push the current branch to the appropriate remote. +- Do not force push unless the user explicitly asks after seeing the risk. + +If the user confirms PR creation: + +- Use GitHub CLI when available. +- Create the PR against `Development`. +- Default to a draft PR unless the user asks for a ready-for-review PR. +- Include a PR body with summary, validation, tests, release notes status, and known risks. +- Return the PR URL. + +If GitHub CLI is unavailable or unauthenticated, report the exact blocker and provide the PR title/body text for manual creation. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 05e2a5ac0..31e151b24 100644 --- a/.gitignore +++ b/.gitignore @@ -1,55 +1,55 @@ - -# Azure Functions artifacts -bin -obj -appsettings.json -local.settings.json - -# Azurite artifacts -__blobstorage__ -__queuestorage__ -__azurite_db*__.json -.python_packages -__pycache__ -.venv - -# Azure App Service artifacts -.env -.pem -.deployment - -# macOS custom attributes file -.ds_store - -# Configured custom script extensions, powershell scripts, and linux scripts -priv-* - -# Visual Studio Code -.vscode - -# temporary files -flask_session - -# node modules -/node_modules -/package-lock.json -/package.json -application/single_app/static/.DS_Store -application/external_apps/bulkloader/map.csv - -flask_session -**/abd_proto.py -**/my_chart.png -**/sample_pie.csv -**/sample_stacked_column.csv -tmp**cwd -/tmp_images -nul -/.github/plans -*.xlsx -/artifacts/tests -/artifacts/tmp/* -/artifacts/tmp -scripts/agent.json -scripts/me.json -.github/instructions/python-venv-path.instructions.md \ No newline at end of file + +# Azure Functions artifacts +bin +obj +appsettings.json +local.settings.json + +# Azurite artifacts +__blobstorage__ +__queuestorage__ +__azurite_db*__.json +.python_packages +__pycache__ +.venv + +# Azure App Service artifacts +.env +.pem +.deployment + +# macOS custom attributes file +.ds_store + +# Configured custom script extensions, powershell scripts, and linux scripts +priv-* + +# Visual Studio Code +.vscode + +# temporary files +flask_session + +# node modules +/node_modules +/package-lock.json +/package.json +application/single_app/static/.DS_Store +application/external_apps/bulkloader/map.csv + +flask_session +**/abd_proto.py +**/my_chart.png +**/sample_pie.csv +**/sample_stacked_column.csv +tmp**cwd +/tmp_images +nul +/.github/plans +*.xlsx +/artifacts/tests +/artifacts/tmp/* +/artifacts/tmp +scripts/agent.json +scripts/me.json +.github/instructions/local.instructions.md diff --git a/application/single_app/app.py b/application/single_app/app.py index 422133651..711931da8 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -52,6 +52,7 @@ from route_frontend_feedback import * from route_frontend_support import * from route_frontend_notifications import * +from route_custom_pages import register_route_custom_pages from route_backend_chats import * from route_backend_conversations import * @@ -68,6 +69,7 @@ from route_backend_control_center import * from route_backend_notifications import * from route_backend_retention_policy import * +from route_backend_governance import register_route_backend_governance from route_backend_plugins import bpap as admin_plugins_bp, bpdp as dynamic_plugins_bp from route_backend_agents import bpa as admin_agents_bp from route_backend_agent_templates import bp_agent_templates @@ -84,6 +86,7 @@ from route_openapi import register_openapi_routes from route_migration import bp_migration from route_plugin_logging import bpl as plugin_logging_bp +from functions_custom_pages import get_custom_pages_nav from functions_debug import debug_print from opentelemetry.instrumentation.flask import FlaskInstrumentor @@ -544,6 +547,11 @@ def inject_settings(): public_settings = sanitize_settings_for_user(settings) idle_timeout_enabled = is_idle_timeout_enabled(settings) idle_timeout_minutes, idle_warning_minutes = get_idle_timeout_settings(settings) + custom_pages_nav = [] + try: + custom_pages_nav = get_custom_pages_nav(settings) + except Exception as e: + log_event(f"[CustomPages] Error injecting custom page navigation: {e}", level=logging.ERROR, exceptionTraceback=True) # Inject per-user settings if logged in user_settings = {} try: @@ -558,6 +566,7 @@ def inject_settings(): return dict( app_settings=public_settings, user_settings=user_settings, + custom_pages_nav=custom_pages_nav, idle_timeout_enabled=idle_timeout_enabled, idle_timeout_minutes=idle_timeout_minutes, idle_warning_minutes=idle_warning_minutes @@ -889,6 +898,9 @@ def list_semantic_kernel_plugins(): # ------------------- Notifications Routes -------------- register_route_frontend_notifications(app) +# ------------------- Custom Pages Routes --------------- +register_route_custom_pages(app) + # ------------------- API Chat Routes -------------------- register_route_backend_chats(app) @@ -934,6 +946,9 @@ def list_semantic_kernel_plugins(): # ------------------- API Retention Policy Routes -------- register_route_backend_retention_policy(app) +# ------------------- API Governance Routes -------------- +register_route_backend_governance(app) + # ------------------- API Public Workspaces Routes ------- register_route_backend_public_workspaces(app) diff --git a/application/single_app/app_settings_cache.py b/application/single_app/app_settings_cache.py index b9c4a3cc2..44e28367a 100644 --- a/application/single_app/app_settings_cache.py +++ b/application/single_app/app_settings_cache.py @@ -8,6 +8,7 @@ import logging import threading import time +from datetime import datetime from redis import Redis from azure.identity import DefaultAzureCredential @@ -16,17 +17,33 @@ # functions_appinsights is also imported locally for the same reason. _settings = None +_logger = logging.getLogger(__name__) APP_SETTINGS_CACHE = {} APP_STREAM_SESSION_METADATA = {} APP_STREAM_SESSION_EVENTS = {} +APP_SETTINGS_CACHE_VERSION = 0 +APP_GOVERNANCE_CACHE_VERSION = 0 +APP_SETTINGS_SHARED_VERSION_CACHE = {'value': 0, 'expires_at': 0} +APP_GOVERNANCE_SHARED_VERSION_CACHE = {'value': 0, 'expires_at': 0} +APP_SETTINGS_CACHE_KEY = 'APP_SETTINGS_CACHE' +APP_SETTINGS_CACHE_VERSION_KEY = 'APP_SETTINGS_CACHE_VERSION' +APP_SETTINGS_CACHE_VERSION_DOC_ID = 'app_settings_cache_version' +GOVERNANCE_CACHE_VERSION_KEY = 'GOVERNANCE_CACHE_VERSION' +GOVERNANCE_CACHE_VERSION_DOC_ID = 'governance_cache_version' +CACHE_VERSION_DOC_TYPE = 'cache_version' +CACHE_VERSION_READ_TTL_SECONDS = 15 update_settings_cache = None get_settings_cache = None +get_app_settings_cache_version = None +bump_app_settings_cache_version = None initialize_stream_session_cache = None set_stream_session_meta = None get_stream_session_meta = None append_stream_session_event = None get_stream_session_events = None delete_stream_session_cache = None +get_governance_cache_version = None +bump_governance_cache_version = None app_cache_is_using_redis = False _app_cache_lock = threading.Lock() @@ -43,11 +60,72 @@ def _is_expired(entry): expires_at = entry.get('expires_at') return expires_at is not None and expires_at <= time.time() + +def _normalize_cache_version(value): + if isinstance(value, bytes): + value = value.decode('utf-8') + try: + return int(value or 0) + except (TypeError, ValueError): + return 0 + + +def _read_cosmos_cache_version(container, doc_id, log_event_func=None): + try: + doc = container.read_item(item=doc_id, partition_key=doc_id) + return _normalize_cache_version((doc or {}).get('version')) + except Exception as ex: + _logger.warning("[ASC] Shared cache version read failed for %s; using local version fallback: %s", doc_id, ex) + return None + + +def _bump_cosmos_cache_version(container, doc_id, log_event_func=None): + try: + current_version = _read_cosmos_cache_version(container, doc_id, log_event_func=None) + next_version = _normalize_cache_version(current_version) + 1 + container.upsert_item({ + 'id': doc_id, + 'type': CACHE_VERSION_DOC_TYPE, + 'version': next_version, + 'updated_at': datetime.utcnow().isoformat(), + }) + return next_version + except Exception as ex: + _logger.warning("[ASC] Shared cache version bump failed for %s; using local version fallback: %s", doc_id, ex) + return None + + +def _get_ttl_cached_cosmos_version(version_cache, container, doc_id, fallback_version, log_event_func=None): + now = time.time() + with _app_cache_lock: + cached_expires_at = version_cache.get('expires_at') or 0 + if cached_expires_at > now: + return _normalize_cache_version(version_cache.get('value')) + + shared_version = _read_cosmos_cache_version(container, doc_id, log_event_func=log_event_func) + if shared_version is None: + shared_version = fallback_version + + with _app_cache_lock: + version_cache['value'] = _normalize_cache_version(shared_version) + version_cache['expires_at'] = now + CACHE_VERSION_READ_TTL_SECONDS + return _normalize_cache_version(version_cache.get('value')) + + +def _set_ttl_cached_version(version_cache, version): + with _app_cache_lock: + version_cache['value'] = _normalize_cache_version(version) + version_cache['expires_at'] = time.time() + CACHE_VERSION_READ_TTL_SECONDS + def configure_app_cache(settings, redis_cache_endpoint=None): global _settings, update_settings_cache, get_settings_cache, APP_SETTINGS_CACHE global APP_STREAM_SESSION_METADATA, APP_STREAM_SESSION_EVENTS + global APP_SETTINGS_CACHE_VERSION, APP_GOVERNANCE_CACHE_VERSION + global APP_SETTINGS_SHARED_VERSION_CACHE, APP_GOVERNANCE_SHARED_VERSION_CACHE global initialize_stream_session_cache, set_stream_session_meta, get_stream_session_meta global append_stream_session_event, get_stream_session_events, delete_stream_session_cache + global get_app_settings_cache_version, bump_app_settings_cache_version + global get_governance_cache_version, bump_governance_cache_version global app_cache_is_using_redis # Local import to avoid circular dependency: functions_keyvault imports app_settings_cache. from functions_appinsights import log_event @@ -105,12 +183,48 @@ def configure_app_cache(settings, redis_cache_endpoint=None): ssl=True ) + def get_app_settings_cache_version_redis(): + cached = redis_client.get(APP_SETTINGS_CACHE_VERSION_KEY) + if cached is None: + redis_client.setnx(APP_SETTINGS_CACHE_VERSION_KEY, 0) + return 0 + return _normalize_cache_version(cached) + + def bump_app_settings_cache_version_redis(): + return _normalize_cache_version(redis_client.incr(APP_SETTINGS_CACHE_VERSION_KEY)) + + def get_ttl_cached_app_settings_version_redis(): + now = time.time() + with _app_cache_lock: + if APP_SETTINGS_SHARED_VERSION_CACHE.get('expires_at', 0) > now: + return _normalize_cache_version(APP_SETTINGS_SHARED_VERSION_CACHE.get('value')) + + shared_version = get_app_settings_cache_version_redis() + _set_ttl_cached_version(APP_SETTINGS_SHARED_VERSION_CACHE, shared_version) + return shared_version + def update_settings_cache_redis(new_settings): - redis_client.set('APP_SETTINGS_CACHE', json.dumps(new_settings)) + global APP_SETTINGS_CACHE, APP_SETTINGS_CACHE_VERSION + redis_client.set(APP_SETTINGS_CACHE_KEY, json.dumps(new_settings)) + shared_version = get_app_settings_cache_version_redis() + with _app_cache_lock: + APP_SETTINGS_CACHE = new_settings + APP_SETTINGS_CACHE_VERSION = shared_version + _set_ttl_cached_version(APP_SETTINGS_SHARED_VERSION_CACHE, shared_version) def get_settings_cache_redis(): - cached = redis_client.get('APP_SETTINGS_CACHE') - return json.loads(cached) if cached else {} + global APP_SETTINGS_CACHE, APP_SETTINGS_CACHE_VERSION + shared_version = get_ttl_cached_app_settings_version_redis() + with _app_cache_lock: + if APP_SETTINGS_CACHE and APP_SETTINGS_CACHE_VERSION == shared_version: + return APP_SETTINGS_CACHE + + cached = redis_client.get(APP_SETTINGS_CACHE_KEY) + loaded_settings = json.loads(cached) if cached else {} + with _app_cache_lock: + APP_SETTINGS_CACHE = loaded_settings + APP_SETTINGS_CACHE_VERSION = shared_version + return loaded_settings def get_stream_session_metadata_key(cache_key): return f'STREAM_SESSION_META:{cache_key}' @@ -174,22 +288,58 @@ def delete_stream_session_cache_redis(cache_key): get_stream_session_events_key(cache_key), ) + def get_governance_cache_version_redis(): + cached = redis_client.get(GOVERNANCE_CACHE_VERSION_KEY) + if cached is None: + redis_client.setnx(GOVERNANCE_CACHE_VERSION_KEY, 0) + return 0 + return _normalize_cache_version(cached) + + def bump_governance_cache_version_redis(): + return _normalize_cache_version(redis_client.incr(GOVERNANCE_CACHE_VERSION_KEY)) + update_settings_cache = update_settings_cache_redis get_settings_cache = get_settings_cache_redis + get_app_settings_cache_version = get_app_settings_cache_version_redis + bump_app_settings_cache_version = bump_app_settings_cache_version_redis initialize_stream_session_cache = initialize_stream_session_cache_redis set_stream_session_meta = set_stream_session_meta_redis get_stream_session_meta = get_stream_session_meta_redis append_stream_session_event = append_stream_session_event_redis get_stream_session_events = get_stream_session_events_redis delete_stream_session_cache = delete_stream_session_cache_redis + get_governance_cache_version = get_governance_cache_version_redis + bump_governance_cache_version = bump_governance_cache_version_redis else: def update_settings_cache_mem(new_settings): - global APP_SETTINGS_CACHE - APP_SETTINGS_CACHE = new_settings + global APP_SETTINGS_CACHE, APP_SETTINGS_CACHE_VERSION + shared_version = get_app_settings_cache_version_mem() + with _app_cache_lock: + APP_SETTINGS_CACHE = new_settings + APP_SETTINGS_CACHE_VERSION = shared_version def get_settings_cache_mem(): - return APP_SETTINGS_CACHE + global APP_SETTINGS_CACHE, APP_SETTINGS_CACHE_VERSION + shared_version = get_app_settings_cache_version_mem() + with _app_cache_lock: + if APP_SETTINGS_CACHE and APP_SETTINGS_CACHE_VERSION == shared_version: + return APP_SETTINGS_CACHE + + try: + from config import cosmos_settings_container + loaded_settings = cosmos_settings_container.read_item( + item='app_settings', + partition_key='app_settings', + ) + with _app_cache_lock: + APP_SETTINGS_CACHE = loaded_settings + APP_SETTINGS_CACHE_VERSION = shared_version + return loaded_settings + except Exception as ex: + _logger.warning("[ASC] Failed to refresh app settings cache from Cosmos; using local cache fallback: %s", ex) + with _app_cache_lock: + return APP_SETTINGS_CACHE def initialize_stream_session_cache_mem(cache_key, metadata, ttl_seconds=None): expiration_timestamp = _get_expiration_timestamp(ttl_seconds) @@ -258,11 +408,91 @@ def delete_stream_session_cache_mem(cache_key): APP_STREAM_SESSION_METADATA.pop(cache_key, None) APP_STREAM_SESSION_EVENTS.pop(cache_key, None) + def get_app_settings_cache_version_mem(): + global APP_SETTINGS_CACHE_VERSION + try: + from config import cosmos_settings_container + return _get_ttl_cached_cosmos_version( + APP_SETTINGS_SHARED_VERSION_CACHE, + cosmos_settings_container, + APP_SETTINGS_CACHE_VERSION_DOC_ID, + APP_SETTINGS_CACHE_VERSION, + log_event_func=log_event, + ) + except Exception: + with _app_cache_lock: + return APP_SETTINGS_CACHE_VERSION + + def bump_app_settings_cache_version_mem(): + global APP_SETTINGS_CACHE_VERSION + try: + from config import cosmos_settings_container + bumped_version = _bump_cosmos_cache_version( + cosmos_settings_container, + APP_SETTINGS_CACHE_VERSION_DOC_ID, + log_event_func=log_event, + ) + if bumped_version is not None: + with _app_cache_lock: + APP_SETTINGS_CACHE_VERSION = bumped_version + _set_ttl_cached_version(APP_SETTINGS_SHARED_VERSION_CACHE, bumped_version) + return bumped_version + except Exception: + pass + + with _app_cache_lock: + APP_SETTINGS_CACHE_VERSION += 1 + fallback_version = APP_SETTINGS_CACHE_VERSION + _set_ttl_cached_version(APP_SETTINGS_SHARED_VERSION_CACHE, fallback_version) + return fallback_version + + def get_governance_cache_version_mem(): + global APP_GOVERNANCE_CACHE_VERSION + try: + from config import cosmos_governance_policies_container + return _get_ttl_cached_cosmos_version( + APP_GOVERNANCE_SHARED_VERSION_CACHE, + cosmos_governance_policies_container, + GOVERNANCE_CACHE_VERSION_DOC_ID, + APP_GOVERNANCE_CACHE_VERSION, + log_event_func=log_event, + ) + except Exception: + with _app_cache_lock: + return APP_GOVERNANCE_CACHE_VERSION + + def bump_governance_cache_version_mem(): + global APP_GOVERNANCE_CACHE_VERSION + try: + from config import cosmos_governance_policies_container + bumped_version = _bump_cosmos_cache_version( + cosmos_governance_policies_container, + GOVERNANCE_CACHE_VERSION_DOC_ID, + log_event_func=log_event, + ) + if bumped_version is not None: + with _app_cache_lock: + APP_GOVERNANCE_CACHE_VERSION = bumped_version + _set_ttl_cached_version(APP_GOVERNANCE_SHARED_VERSION_CACHE, bumped_version) + return bumped_version + except Exception: + pass + + with _app_cache_lock: + APP_GOVERNANCE_CACHE_VERSION += 1 + fallback_version = APP_GOVERNANCE_CACHE_VERSION + _set_ttl_cached_version(APP_GOVERNANCE_SHARED_VERSION_CACHE, fallback_version) + return fallback_version + update_settings_cache = update_settings_cache_mem get_settings_cache = get_settings_cache_mem + get_app_settings_cache_version = get_app_settings_cache_version_mem + bump_app_settings_cache_version = bump_app_settings_cache_version_mem initialize_stream_session_cache = initialize_stream_session_cache_mem set_stream_session_meta = set_stream_session_meta_mem get_stream_session_meta = get_stream_session_meta_mem append_stream_session_event = append_stream_session_event_mem get_stream_session_events = get_stream_session_events_mem - delete_stream_session_cache = delete_stream_session_cache_mem \ No newline at end of file + delete_stream_session_cache = delete_stream_session_cache_mem + get_governance_cache_version = get_governance_cache_version_mem + bump_governance_cache_version = bump_governance_cache_version_mem \ No newline at end of file diff --git a/application/single_app/config.py b/application/single_app/config.py index 30cb1b95f..d5fc1498b 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.241.007" +VERSION = "0.242.043" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') @@ -358,6 +358,12 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/id") ) +cosmos_custom_pages_container_name = "custom_pages" +cosmos_custom_pages_container = cosmos_database.create_container_if_not_exists( + id=cosmos_custom_pages_container_name, + partition_key=PartitionKey(path="/id") +) + cosmos_groups_container_name = "groups" cosmos_groups_container = cosmos_database.create_container_if_not_exists( id=cosmos_groups_container_name, @@ -478,6 +484,18 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: partition_key=PartitionKey(path="/id") ) +cosmos_governance_policies_container_name = "governance_policies" +cosmos_governance_policies_container = cosmos_database.create_container_if_not_exists( + id=cosmos_governance_policies_container_name, + partition_key=PartitionKey(path="/id") +) + +cosmos_governance_item_policies_container_name = "governance_item_policies" +cosmos_governance_item_policies_container = cosmos_database.create_container_if_not_exists( + id=cosmos_governance_item_policies_container_name, + partition_key=PartitionKey(path="/id") +) + cosmos_agent_templates_container_name = "agent_templates" cosmos_agent_templates_container = cosmos_database.create_container_if_not_exists( id=cosmos_agent_templates_container_name, diff --git a/application/single_app/custom_page_extension.py b/application/single_app/custom_page_extension.py new file mode 100644 index 000000000..a2afa8c9f --- /dev/null +++ b/application/single_app/custom_page_extension.py @@ -0,0 +1,39 @@ +# custom_page_extension.py +""" +Trusted custom page extension contract for deployment-time SimpleChat customizations. +""" + +from typing import Any, Dict, Optional + + +def custom_page(**metadata): + """Attach SimpleChat custom page metadata to an extension class or factory.""" + def decorator(extension_object): + extension_object.__simplechat_custom_page__ = metadata + return extension_object + + return decorator + + +class CustomPageExtension: + """Base class for trusted Python-backed custom pages.""" + + metadata: Dict[str, Any] = {} + + def __init__(self, metadata_override: Optional[Dict[str, Any]] = None): + if metadata_override: + merged_metadata = dict(self.metadata or {}) + merged_metadata.update(metadata_override) + self.metadata = merged_metadata + + def render(self, context: Dict[str, Any]): + """Render the custom page response.""" + raise NotImplementedError("Custom page extensions must implement render(context).") + + def handle_api(self, operation: str, context: Dict[str, Any]): + """Handle a custom page API operation.""" + raise NotImplementedError("This custom page does not expose API operations.") + + def health_check(self, context: Dict[str, Any]) -> Dict[str, Any]: + """Return optional health details for the extension.""" + return {"healthy": True} \ No newline at end of file diff --git a/application/single_app/custom_pages/assets/.gitkeep b/application/single_app/custom_pages/assets/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/custom_pages/css/.gitkeep b/application/single_app/custom_pages/css/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/custom_pages/css/request-access.css b/application/single_app/custom_pages/css/request-access.css new file mode 100644 index 000000000..3c5c4ef7d --- /dev/null +++ b/application/single_app/custom_pages/css/request-access.css @@ -0,0 +1,39 @@ +/* request-access.css */ +.request-access-page { + display: flex; + justify-content: center; + padding: 3rem 1rem; +} + +.request-access-page__content { + width: min(100%, 42rem); + border: 1px solid var(--bs-border-color); + border-radius: 8px; + padding: 2rem; + background: var(--bs-body-bg); + text-align: center; +} + +.request-access-page__eyebrow { + margin-bottom: 0.5rem; + color: var(--bs-secondary-color); + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.request-access-page__title { + margin-bottom: 0.75rem; + font-size: 2rem; +} + +.request-access-page__summary { + margin: 0 auto 1.5rem; + max-width: 34rem; + color: var(--bs-secondary-color); +} + +.request-access-page__button { + min-width: 12rem; +} \ No newline at end of file diff --git a/application/single_app/custom_pages/html/.gitkeep b/application/single_app/custom_pages/html/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/custom_pages/html/request-access.html b/application/single_app/custom_pages/html/request-access.html new file mode 100644 index 000000000..bccb0fb9e --- /dev/null +++ b/application/single_app/custom_pages/html/request-access.html @@ -0,0 +1,16 @@ + +
+
+

SimpleChat access

+

Request access to SimpleChat

+

+ Your account is signed in, but it does not yet have the base user role required to use this SimpleChat instance. +

+ + Request Access + +
+
\ No newline at end of file diff --git a/application/single_app/custom_pages/js/.gitkeep b/application/single_app/custom_pages/js/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/custom_pages/json/.gitkeep b/application/single_app/custom_pages/json/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/custom_pages/python/.gitkeep b/application/single_app/custom_pages/python/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/application/single_app/docs/how-to/custom_pages.md b/application/single_app/docs/how-to/custom_pages.md new file mode 100644 index 000000000..dd57bf00b --- /dev/null +++ b/application/single_app/docs/how-to/custom_pages.md @@ -0,0 +1,162 @@ +# Create Custom Pages + +Custom Pages let deployment teams add trusted pages under `/custom` without editing the core SimpleChat route table. Use this guide for the built-in Request Access page and the three supported patterns: simple HTML, static HTML/CSS/JS, and Python-backed Jinja/API pages. + +Custom Pages are trusted deployment-time content. They are not runtime user uploads. Static page metadata is stored in the `custom_pages` Cosmos container, while files live in the deployed artifact under `application/single_app/custom_pages/`. + +## Before You Start + +1. Deploy files under `application/single_app/custom_pages/` in the matching folders: `html`, `css`, `js`, `assets`, `json`, and `python`. +2. Enable Custom Pages in Admin Settings > Custom Pages. +3. Acknowledge the restart requirement when enabling the feature from a disabled state. +4. Restart the App Service or local Flask process after deploying new files or Python-backed pages. + +The canonical route format is `/custom/`. Static-looking aliases such as `/custom/.html` are also supported. + +## Built-In Request Access Page + +SimpleChat includes a practical static page that helps signed-in users request the base `User` role. It is not automatically inserted into Cosmos DB. Admins can instantiate it from Admin Settings > Custom Pages by selecting **Add Request Access Page**. + +The one-click action creates static page metadata and enables the access-denied button on the home page for signed-in users who do not yet have an app role. + +Request Access metadata: + +| Field | Value | +| --- | --- | +| Slug | `request-access` | +| Title | `Request SimpleChat Access` | +| Description | `A signed-in access request page for users who need the base User role.` | +| Navigation Label | `Request Access` | +| Bootstrap Icon | `bi-person-plus` | +| Order | `5` | +| Access Level | `Any signed-in user` | +| Allowed Roles | leave blank | +| HTML File | `request-access.html` | +| CSS Files | add `request-access.css` | +| JavaScript Files | no files | +| Asset Files | no files | +| JSON Files | no files | +| Enabled | on | +| Show in Navigation | off | +| Open in New Tab | off | + +The page opens the user's email client with: + +- To: `accessrequest@example.com` +- Subject: `SimpleChat Access Request` +- Body: `Please grant me the user role in simplechat so that I may start using this wonderful tool` + +## Access Levels + +Static page metadata has an Access Level field: + +- `App users only`: requires the signed-in user to have `User` or `Admin` before page-specific roles are checked. +- `Any signed-in user`: requires login, but does not require the base app role before page-specific roles are checked. + +Use `Any signed-in user` sparingly. It is intended for bootstrap flows like Request Access. Most custom pages should stay `App users only`. + +## Pattern 1: Simple HTML Page + +Use this when the page only needs trusted HTML and Bootstrap classes already available from the SimpleChat shell. + +Place the HTML fragment in `application/single_app/custom_pages/html/`. + +Example file: `example-simple.html` + +Sample source: `docs/how-to/custom_pages_examples/simple-html/example-simple.html` + +Metadata editor values: + +| Field | Value | +| --- | --- | +| Slug | `example-simple` | +| Title | `Simple HTML Example` | +| Description | `A minimal custom page loaded from a trusted HTML fragment.` | +| Navigation Label | `Simple HTML` | +| Bootstrap Icon | `bi-file-earmark-text` | +| Order | `10` | +| Allowed Roles | leave blank for all signed-in users, or use a role such as `User` | +| HTML File | `example-simple.html` | +| CSS Files | no files | +| JavaScript Files | no files | +| Asset Files | no files | +| JSON Files | no files | +| Enabled | on | +| Show in Navigation | on | +| Open in New Tab | off | + +After saving metadata, test `/custom/example-simple` and `/custom/example-simple.html`. + +## Pattern 2: Static HTML, CSS, and JavaScript Page + +Use this when a static page needs page-specific styling or client-side behavior. + +Place files in these folders: + +- HTML in `application/single_app/custom_pages/html/` +- CSS in `application/single_app/custom_pages/css/` +- JavaScript modules in `application/single_app/custom_pages/js/` +- Images and other static files in `application/single_app/custom_pages/assets/` +- JSON data in `application/single_app/custom_pages/json/` + +Example files: + +- `html/example-static.html` +- `css/example-static.css` +- `js/example-static.js` +- `assets/cat.mp4` + +Sample source folder: `docs/how-to/custom_pages_examples/static-html-css-js/` + +Metadata editor values: + +| Field | Value | +| --- | --- | +| Slug | `example-static` | +| Title | `Static HTML with CSS/JS Example` | +| Description | `A custom static page with deployed HTML, CSS, and JavaScript.` | +| Navigation Label | `Static Example` | +| Bootstrap Icon | `bi-window` | +| Order | `20` | +| Allowed Roles | leave blank for all signed-in users, or use a role such as `User` | +| HTML File | `example-static.html` | +| CSS Files | add `example-static.css` | +| JavaScript Files | add `example-static.js` | +| Asset Files | add `cat.mp4` | +| JSON Files | add only JSON or CSV files the page must fetch through `/custom/assets/...` | +| Enabled | on | +| Show in Navigation | on | +| Open in New Tab | off | + +The metadata designer stores CSS, JavaScript, asset, and JSON references as arrays. Add one file at a time in the modal so the page only serves files explicitly declared by metadata. + +## Pattern 3: Python-Backed Jinja Page with Backend API + +Use this when the page needs server-side rendering, access to request context, or a custom backend operation. + +Place the Python extension in `application/single_app/custom_pages/python/`. The extension must subclass `CustomPageExtension` and provide metadata with a unique `slug`. It can render a template from `custom_pages/html/` and handle API operations through `/api/custom//`. + +Example files: + +- `python/example_python_dashboard.py` +- `html/example-python-dashboard.html` +- `css/example-python-dashboard.css` +- `js/example-python-dashboard.js` + +Sample source folder: `docs/how-to/custom_pages_examples/python-jinja-api/` + +Do not create Python-backed pages in the metadata editor. Python-backed pages are discovered from code at app startup. Restart the App Service after deploying or changing Python-backed page files. + +The example page appears at `/custom/example-python-dashboard`, and its sample API endpoint is `/api/custom/example-python-dashboard/status`. + +## Role Behavior + +Leave Allowed Roles blank to allow any signed-in user. Use role names such as `User` to restrict access. `Admin` satisfies `User` because `User` is the base application role. For any other role, every user, including admins, must have at least one exact matching role in their current SimpleChat session. A role name that does not exist in the app registration will not appear in user tokens, so it will deny access unless the role is otherwise present in the session. + +## Troubleshooting + +- If a static page does not appear in navigation, verify the metadata is enabled and `Show in Navigation` is on. +- If a static page returns `Not Found`, verify the slug, HTML file name, feature toggle, and App Service restart state. +- If CSS or JavaScript does not load, verify the file is listed in metadata and exists in the matching folder. +- If a Python-backed page does not appear, restart the App Service after deploying the Python file. +- If `/custom/` works but `/custom/.html` does not, restart the app to load the latest route registration. \ No newline at end of file diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index 92c78dfb8..666f012f5 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -1788,6 +1788,70 @@ def log_general_admin_action( debug_print(f"⚠️ Warning: Failed to log admin action: {str(e)}") +def log_governance_change( + admin_user_id: str, + admin_email: str, + action: str, + scope: str, + target_id: str, + before_state: Optional[Dict[str, Any]] = None, + after_state: Optional[Dict[str, Any]] = None, + change_details: Optional[Dict[str, Any]] = None, +) -> None: + """Log governance mutations with detailed before/after payloads.""" + normalized_admin_user_id = coerce_activity_log_user_id(admin_user_id) + timestamp = datetime.utcnow().isoformat() + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': normalized_admin_user_id, + 'activity_type': 'governance', + 'type': 'governance', + 'timestamp': timestamp, + 'created_at': timestamp, + 'admin': { + 'user_id': normalized_admin_user_id, + 'email': admin_email, + }, + 'action': action, + 'workspace_type': 'admin', + 'workspace_context': { + 'action': action, + 'scope': scope, + 'target_id': target_id, + }, + 'governance_change': { + 'scope': scope, + 'target_id': target_id, + 'before': before_state or {}, + 'after': after_state or {}, + 'details': change_details or {}, + }, + } + + try: + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Governance change logged: {action} on {scope}/{target_id}", + extra=activity_record, + level=logging.INFO, + ) + debug_print(f"✅ Governance change logged: {action} on {scope}/{target_id}") + except Exception as e: + log_event( + message=f"Error logging governance change: {str(e)}", + extra={ + 'admin_user_id': normalized_admin_user_id, + 'admin_email': admin_email, + 'action': action, + 'scope': scope, + 'target_id': target_id, + 'error': str(e), + }, + level=logging.ERROR, + ) + debug_print(f"⚠️ Warning: Failed to log governance change: {str(e)}") + + # === AGENT & ACTION ACTIVITY LOGGING === def log_agent_creation( user_id: str, diff --git a/application/single_app/functions_custom_pages.py b/application/single_app/functions_custom_pages.py new file mode 100644 index 000000000..fad7e96f3 --- /dev/null +++ b/application/single_app/functions_custom_pages.py @@ -0,0 +1,428 @@ +# functions_custom_pages.py +"""Custom Pages registry, validation, discovery, and navigation helpers.""" + +import importlib.util +import inspect +import logging +import os +import re +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from azure.cosmos import exceptions +from flask import has_app_context, session, url_for + +from config import cosmos_custom_pages_container +from custom_page_extension import CustomPageExtension +from functions_appinsights import log_event + + +CUSTOM_PAGES_DIR = os.path.join(os.path.dirname(__file__), "custom_pages") +CUSTOM_PAGE_SOURCE_COSMOS = "cosmos" +CUSTOM_PAGE_SOURCE_PYTHON = "python" +SLUG_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") +ENTRY_TYPES = {"static", "python"} +ACCESS_LEVELS = {"app_user", "authenticated"} +ASSET_FOLDERS = { + "html": {".html", ".htm"}, + "css": {".css"}, + "js": {".js", ".mjs"}, + "assets": { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", + ".woff", ".woff2", ".ttf", ".otf", ".pdf", ".mp4", ".webm", + }, + "json": {".json", ".csv"}, +} +LIST_FIELDS = ("roles", "css_files", "js_files", "asset_files", "json_files") +PYTHON_EXTENSION_CACHE: Optional[Dict[str, Dict[str, Any]]] = None + + +def is_custom_pages_enabled(settings: Optional[Dict[str, Any]] = None) -> bool: + """Return True when custom pages are globally enabled.""" + return bool((settings or {}).get("enable_custom_pages", False)) + + +def is_safe_slug(slug: str) -> bool: + """Return True when a slug is safe for URL and lookup use.""" + return isinstance(slug, str) and bool(SLUG_PATTERN.fullmatch(slug.strip())) + + +def normalize_custom_page_metadata( + metadata: Dict[str, Any], + source: str = CUSTOM_PAGE_SOURCE_COSMOS, +) -> Dict[str, Any]: + """Normalize a custom page metadata payload into the persisted contract.""" + page = dict(metadata or {}) + slug = str(page.get("slug") or page.get("id") or "").strip().lower() + title = str(page.get("title") or page.get("nav_label") or slug).strip() + nav_label = str(page.get("nav_label") or title or slug).strip() + entry_type = str(page.get("entry_type") or "static").strip().lower() + + normalized = { + "id": slug, + "slug": slug, + "title": title, + "description": str(page.get("description") or "").strip(), + "enabled": _coerce_bool(page.get("enabled", True)), + "entry_type": entry_type, + "access_level": str(page.get("access_level") or "app_user").strip().lower(), + "nav_label": nav_label, + "nav_icon": _normalize_icon(page.get("nav_icon")), + "nav_order": _coerce_int(page.get("nav_order"), 100), + "roles": _normalize_string_list(page.get("roles")), + "show_in_nav": _coerce_bool(page.get("show_in_nav", True)), + "open_in_new_tab": _coerce_bool(page.get("open_in_new_tab", False)), + "render_jinja": _coerce_bool(page.get("render_jinja", False)), + "html_file": _normalize_optional_path(page.get("html_file")), + "css_files": _normalize_string_list(page.get("css_files")), + "js_files": _normalize_string_list(page.get("js_files")), + "asset_files": _normalize_string_list(page.get("asset_files")), + "json_files": _normalize_string_list(page.get("json_files")), + "source": source, + } + + for optional_key in ("module", "class_name", "decorated_extension_id", "blueprint_name"): + if page.get(optional_key): + normalized[optional_key] = str(page.get(optional_key)).strip() + + return normalized + + +def validate_custom_page_metadata(metadata: Dict[str, Any], require_static_html: bool = False) -> List[str]: + """Validate a custom page metadata payload and return error messages.""" + page = normalize_custom_page_metadata(metadata, source=metadata.get("source", CUSTOM_PAGE_SOURCE_COSMOS)) + errors = [] + + if not is_safe_slug(page.get("slug")): + errors.append("Slug must start with a lowercase letter or number and use only lowercase letters, numbers, hyphens, or underscores.") + if not page.get("title"): + errors.append("Title is required.") + if page.get("entry_type") not in ENTRY_TYPES: + errors.append("entry_type must be either static or python.") + if page.get("access_level") not in ACCESS_LEVELS: + errors.append("access_level must be either app_user or authenticated.") + if page.get("entry_type") == "static" and require_static_html and not page.get("html_file"): + errors.append("Static custom pages require an HTML file.") + + if page.get("html_file"): + errors.extend(validate_custom_page_file_reference("html", page["html_file"])) + for file_name in page.get("css_files", []): + errors.extend(validate_custom_page_file_reference("css", file_name)) + for file_name in page.get("js_files", []): + errors.extend(validate_custom_page_file_reference("js", file_name)) + for file_name in page.get("asset_files", []): + errors.extend(validate_custom_page_file_reference("assets", file_name)) + for file_name in page.get("json_files", []): + errors.extend(validate_custom_page_file_reference("json", file_name)) + + return errors + + +def validate_custom_page_file_reference(folder: str, file_name: str) -> List[str]: + """Validate a metadata file reference without requiring file existence.""" + errors = [] + if folder not in ASSET_FOLDERS: + return [f"Unsupported custom page folder: {folder}"] + if not isinstance(file_name, str) or not file_name.strip(): + return [f"{folder} file reference cannot be empty."] + normalized = file_name.replace("\\", "/").strip() + if normalized.startswith("/") or ".." in normalized.split("/"): + errors.append(f"Unsafe {folder} file path: {file_name}") + extension = os.path.splitext(normalized)[1].lower() + if extension not in ASSET_FOLDERS[folder]: + errors.append(f"Unsupported {folder} file extension for {file_name}.") + return errors + + +def list_custom_pages(include_python: bool = True) -> List[Dict[str, Any]]: + """Return custom page metadata from Cosmos and optional Python extensions.""" + pages = {} + for page in list_cosmos_custom_pages(): + pages[page["slug"]] = page + + if include_python: + for slug, page in discover_python_custom_pages().items(): + pages[slug] = page + + return sorted(pages.values(), key=lambda item: (item.get("nav_order", 100), item.get("nav_label", "").lower())) + + +def list_cosmos_custom_pages() -> List[Dict[str, Any]]: + """Return static custom page metadata stored in Cosmos DB.""" + try: + items = list(cosmos_custom_pages_container.query_items( + query="SELECT * FROM c", + enable_cross_partition_query=True, + )) + return [normalize_custom_page_metadata(_strip_cosmos_fields(item), source=CUSTOM_PAGE_SOURCE_COSMOS) for item in items] + except exceptions.CosmosResourceNotFoundError: + return [] + except Exception as ex: + log_event( + "[CustomPages] Failed to list custom pages.", + extra={"error": str(ex)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return [] + + +def get_custom_page(slug: str, include_python: bool = True) -> Optional[Dict[str, Any]]: + """Return a custom page by slug from Cosmos or Python discovery.""" + if not is_safe_slug(slug): + return None + normalized_slug = slug.strip().lower() + + if include_python: + python_pages = discover_python_custom_pages() + if normalized_slug in python_pages: + return python_pages[normalized_slug] + + try: + item = cosmos_custom_pages_container.read_item(item=normalized_slug, partition_key=normalized_slug) + return normalize_custom_page_metadata(_strip_cosmos_fields(item), source=CUSTOM_PAGE_SOURCE_COSMOS) + except exceptions.CosmosResourceNotFoundError: + return None + except Exception as ex: + log_event( + "[CustomPages] Failed to read custom page.", + extra={"slug": normalized_slug, "error": str(ex)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return None + + +def save_custom_page(metadata: Dict[str, Any], user_id: str = "system") -> Dict[str, Any]: + """Create or update a static custom page metadata record.""" + page = normalize_custom_page_metadata(metadata, source=CUSTOM_PAGE_SOURCE_COSMOS) + page["entry_type"] = "static" + errors = validate_custom_page_metadata(page, require_static_html=True) + if errors: + raise ValueError("; ".join(errors)) + + now = datetime.utcnow().isoformat() + existing = get_custom_page(page["slug"], include_python=False) + page["created_by"] = existing.get("created_by", user_id) if existing else user_id + page["created_at"] = existing.get("created_at", now) if existing else now + page["modified_by"] = user_id + page["modified_at"] = now + result = cosmos_custom_pages_container.upsert_item(body=page) + return normalize_custom_page_metadata(_strip_cosmos_fields(result), source=CUSTOM_PAGE_SOURCE_COSMOS) + + +def delete_custom_page(slug: str) -> bool: + """Delete a static custom page metadata record.""" + if not is_safe_slug(slug): + return False + try: + cosmos_custom_pages_container.delete_item(item=slug, partition_key=slug) + return True + except exceptions.CosmosResourceNotFoundError: + return False + except Exception as ex: + log_event( + "[CustomPages] Failed to delete custom page.", + extra={"slug": slug, "error": str(ex)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + raise + + +def discover_python_custom_pages(force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: + """Discover trusted Python custom page extensions from custom_pages/python.""" + global PYTHON_EXTENSION_CACHE + if PYTHON_EXTENSION_CACHE is not None and not force_refresh: + return PYTHON_EXTENSION_CACHE + + discovered = {} + python_dir = os.path.join(CUSTOM_PAGES_DIR, "python") + if not os.path.isdir(python_dir): + PYTHON_EXTENSION_CACHE = discovered + return discovered + + for file_name in sorted(os.listdir(python_dir)): + if not file_name.endswith(".py") or file_name.startswith("_"): + continue + module_path = os.path.join(python_dir, file_name) + module_name = f"simplechat_custom_pages_{os.path.splitext(file_name)[0]}" + try: + spec = importlib.util.spec_from_file_location(module_name, module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + for _, obj in inspect.getmembers(module): + metadata, extension_instance = _extract_python_extension(obj) + if not metadata: + continue + page = normalize_custom_page_metadata(metadata, source=CUSTOM_PAGE_SOURCE_PYTHON) + page["entry_type"] = "python" + page["module"] = file_name + page["extension"] = extension_instance + errors = validate_custom_page_metadata(page) + if errors: + log_event( + "[CustomPages] Ignored invalid Python custom page.", + extra={"module": file_name, "errors": errors}, + level=logging.WARNING, + ) + continue + discovered[page["slug"]] = page + except Exception as ex: + log_event( + "[CustomPages] Failed to import Python custom page module.", + extra={"module": file_name, "error": str(ex)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + + PYTHON_EXTENSION_CACHE = discovered + return discovered + + +def get_custom_pages_nav(settings: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return authorized custom page navigation items for the current request.""" + if not is_custom_pages_enabled(settings): + return [] + + roles = _current_user_roles() + nav_items = [] + for page in list_custom_pages(include_python=True): + if not page.get("enabled", True) or not page.get("show_in_nav", True): + continue + if not is_custom_page_authorized(page, roles): + continue + if has_app_context(): + href = url_for("custom_page", slug=page["slug"]) + else: + href = f"/custom/{page['slug']}" + nav_items.append({ + "slug": page["slug"], + "label": page.get("nav_label") or page.get("title") or page["slug"], + "icon": page.get("nav_icon") or "bi-file-earmark-text", + "url": href, + "href": href, + "open_in_new_tab": bool(page.get("open_in_new_tab", False)), + "order": page.get("nav_order", 100), + }) + + return sorted(nav_items, key=lambda item: (item.get("order", 100), item.get("label", "").lower())) + + +def is_custom_page_authorized(page: Dict[str, Any], roles: Optional[List[str]] = None) -> bool: + """Return True when the current user roles satisfy custom page metadata.""" + role_set = set(roles if roles is not None else _current_user_roles()) + access_level = page.get("access_level") or "app_user" + if access_level != "authenticated" and not ({"User", "Admin"}.intersection(role_set)): + return False + + required_roles = page.get("roles") or [] + if not required_roles: + return True + required_role_set = set(required_roles) + if role_set.intersection(required_role_set): + return True + return "Admin" in role_set and "User" in required_role_set + + +def resolve_custom_page_file(page: Dict[str, Any], folder: str, file_name: str) -> Tuple[Optional[str], Optional[str]]: + """Resolve a custom page file reference to an absolute path if allowed.""" + errors = validate_custom_page_file_reference(folder, file_name) + if errors: + return None, "; ".join(errors) + + normalized_name = file_name.replace("\\", "/").strip() + base_dir = os.path.abspath(os.path.join(CUSTOM_PAGES_DIR, folder)) + candidate = os.path.abspath(os.path.join(base_dir, normalized_name)) + if os.path.commonpath([base_dir, candidate]) != base_dir: + return None, "File path is outside the allowed custom page folder." + + if not _file_reference_is_declared(page, folder, normalized_name): + return None, "File is not declared by the custom page metadata." + + if not os.path.exists(candidate) or not os.path.isfile(candidate): + return None, "File does not exist." + return candidate, None + + +def build_custom_page_context(page: Dict[str, Any], settings: Dict[str, Any]) -> Dict[str, Any]: + """Build the safe request context passed to trusted custom page extensions.""" + return { + "page": {key: value for key, value in page.items() if key != "extension"}, + "settings": settings, + "user": session.get("user", {}), + "roles": _current_user_roles(), + } + + +def _extract_python_extension(obj: Any) -> Tuple[Optional[Dict[str, Any]], Optional[CustomPageExtension]]: + if inspect.isclass(obj) and issubclass(obj, CustomPageExtension) and obj is not CustomPageExtension: + instance = obj() + metadata = getattr(obj, "__simplechat_custom_page__", None) or getattr(instance, "metadata", None) + return metadata, instance + + metadata = getattr(obj, "__simplechat_custom_page__", None) + if metadata and inspect.isclass(obj): + instance = obj() + return metadata, instance + if metadata and callable(obj): + extension = obj() + if isinstance(extension, CustomPageExtension): + return metadata, extension + return None, None + + +def _file_reference_is_declared(page: Dict[str, Any], folder: str, file_name: str) -> bool: + if folder == "html": + return page.get("html_file") == file_name + field_name = { + "css": "css_files", + "js": "js_files", + "assets": "asset_files", + "json": "json_files", + }.get(folder) + return file_name in set(page.get(field_name, [])) if field_name else False + + +def _normalize_optional_path(value: Any) -> str: + return str(value or "").replace("\\", "/").strip() + + +def _normalize_string_list(value: Any) -> List[str]: + if isinstance(value, list): + return [str(item).replace("\\", "/").strip() for item in value if str(item).strip()] + if isinstance(value, str) and value.strip(): + return [item.replace("\\", "/").strip() for item in value.split(",") if item.strip()] + return [] + + +def _normalize_icon(value: Any) -> str: + icon = str(value or "bi-file-earmark-text").strip() + if not re.fullmatch(r"bi-[a-z0-9-]+", icon): + return "bi-file-earmark-text" + return icon + + +def _coerce_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _coerce_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _strip_cosmos_fields(item: Dict[str, Any]) -> Dict[str, Any]: + return {key: value for key, value in (item or {}).items() if not key.startswith("_")} + + +def _current_user_roles() -> List[str]: + user = session.get("user", {}) if has_app_context() else {} + roles = user.get("roles", []) if isinstance(user, dict) else [] + return roles if isinstance(roles, list) else [] \ No newline at end of file diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index 362729ffd..6bdb813c0 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -9,6 +9,7 @@ from functions_logging import * from functions_authentication import * from functions_debug import * +from functions_keyvault import SecretReturnType, keyvault_model_endpoint_get_helper import azure.cognitiveservices.speech as speechsdk def allowed_file(filename, allowed_extensions=None): @@ -18,6 +19,161 @@ def allowed_file(filename, allowed_extensions=None): filename.rsplit('.', 1)[1].lower() in allowed_extensions +def _normalize_model_endpoint_selection(selection): + if not isinstance(selection, dict): + selection = {} + + return { + "endpoint_id": str(selection.get("endpoint_id") or "").strip(), + "model_id": str(selection.get("model_id") or "").strip(), + "provider": str(selection.get("provider") or "").strip().lower(), + } + + +def _resolve_model_endpoint_authority(auth_settings): + management_cloud = str(auth_settings.get("management_cloud") or "public").lower() + if management_cloud == "government": + return "https://login.microsoftonline.us" + if management_cloud == "custom": + custom_authority = auth_settings.get("custom_authority") or "" + return custom_authority.strip() or None + return None + + +def _resolve_model_endpoint_scope(provider, auth_settings, endpoint=None): + custom_scope = str(auth_settings.get("foundry_scope") or "").strip() + if custom_scope: + return custom_scope + + normalized_provider = str(provider or "aoai").lower() + if normalized_provider not in ("aifoundry", "new_foundry"): + return cognitive_services_scope + + management_cloud = str(auth_settings.get("management_cloud") or "public").lower() + if management_cloud in ("government", "usgovernment", "usgov"): + return "https://ai.azure.us/.default" + if management_cloud == "china": + return "https://ai.azure.cn/.default" + if management_cloud == "germany": + return "https://ai.azure.de/.default" + + endpoint_value = str(endpoint or "").lower() + if "azure.us" in endpoint_value: + return "https://ai.azure.us/.default" + if "azure.cn" in endpoint_value: + return "https://ai.azure.cn/.default" + if "azure.de" in endpoint_value: + return "https://ai.azure.de/.default" + + return "https://ai.azure.com/.default" + + +def _build_model_endpoint_client(auth_settings, provider, endpoint, api_version): + auth_settings = auth_settings or {} + auth_type = str(auth_settings.get("type") or "managed_identity").lower() + + if auth_type in ("api_key", "key"): + api_key = auth_settings.get("api_key") + if not api_key: + raise ValueError("Selected metadata extraction endpoint is missing an API key.") + return AzureOpenAI( + api_version=api_version, + azure_endpoint=endpoint, + api_key=api_key, + ) + + if auth_type == "service_principal": + credential = ClientSecretCredential( + tenant_id=auth_settings.get("tenant_id"), + client_id=auth_settings.get("client_id"), + client_secret=auth_settings.get("client_secret"), + authority=_resolve_model_endpoint_authority(auth_settings), + ) + else: + managed_identity_client_id = auth_settings.get("managed_identity_client_id") or None + credential = DefaultAzureCredential(managed_identity_client_id=managed_identity_client_id) + + scope = _resolve_model_endpoint_scope(provider, auth_settings, endpoint=endpoint) + token_provider = get_bearer_token_provider(credential, scope) + return AzureOpenAI( + api_version=api_version, + azure_endpoint=endpoint, + azure_ad_token_provider=token_provider, + ) + + +def _resolve_metadata_extraction_client(settings): + selection = _normalize_model_endpoint_selection(settings.get("metadata_extraction_model_selection")) + + if ( + settings.get("enable_multi_model_endpoints", False) + and selection["endpoint_id"] + and selection["model_id"] + ): + endpoints, _ = normalize_model_endpoints(settings.get("model_endpoints", []) or []) + endpoint_cfg = next((e for e in endpoints if e.get("id") == selection["endpoint_id"]), None) + if not endpoint_cfg: + raise LookupError("Selected metadata extraction endpoint could not be found.") + if not endpoint_cfg.get("enabled", True): + raise ValueError("Selected metadata extraction endpoint is disabled.") + + endpoint_cfg = keyvault_model_endpoint_get_helper( + endpoint_cfg, + endpoint_cfg.get("id") or selection["endpoint_id"], + scope="global", + return_type=SecretReturnType.VALUE, + ) + + models = endpoint_cfg.get("models", []) or [] + model_cfg = next((m for m in models if m.get("id") == selection["model_id"]), None) + if not model_cfg: + raise LookupError("Selected metadata extraction model could not be found on the endpoint.") + if not model_cfg.get("enabled", True): + raise ValueError("Selected metadata extraction model is disabled.") + + provider = str(endpoint_cfg.get("provider") or selection["provider"] or "aoai").lower() + connection = endpoint_cfg.get("connection", {}) or {} + auth_settings = endpoint_cfg.get("auth", {}) or {} + deployment = str(model_cfg.get("deploymentName") or model_cfg.get("deployment") or "").strip() + endpoint = str(connection.get("endpoint") or "").strip() + api_version = str(connection.get("openai_api_version") or connection.get("api_version") or "").strip() + + if provider not in ("aoai", "aifoundry", "new_foundry"): + raise ValueError(f"Selected metadata extraction provider '{provider}' is not supported.") + if not endpoint or not api_version or not deployment: + raise ValueError("Selected metadata extraction endpoint is missing endpoint, API version, or deployment configuration.") + + return _build_model_endpoint_client(auth_settings, provider, endpoint, api_version), deployment + + gpt_model = settings.get('metadata_extraction_model') + if not gpt_model: + raise ValueError("No metadata extraction model is selected.") + + if settings.get('enable_gpt_apim', False): + return AzureOpenAI( + api_version=settings.get('azure_apim_gpt_api_version'), + azure_endpoint=settings.get('azure_apim_gpt_endpoint'), + api_key=settings.get('azure_apim_gpt_subscription_key') + ), gpt_model + + if settings.get('azure_openai_gpt_authentication_type') == 'managed_identity': + token_provider = get_bearer_token_provider( + DefaultAzureCredential(), + cognitive_services_scope + ) + return AzureOpenAI( + api_version=settings.get('azure_openai_gpt_api_version'), + azure_endpoint=settings.get('azure_openai_gpt_endpoint'), + azure_ad_token_provider=token_provider + ), gpt_model + + return AzureOpenAI( + api_version=settings.get('azure_openai_gpt_api_version'), + azure_endpoint=settings.get('azure_openai_gpt_endpoint'), + api_key=settings.get('azure_openai_gpt_key') + ), gpt_model + + ARCHIVED_SCOPE_PREFIX = "__archived__::" CURRENT_ALIAS_BLOB_PATH_MODE = "current_alias" ARCHIVED_REVISION_BLOB_PATH_MODE = "archived_revision" @@ -3318,7 +3474,6 @@ def extract_document_metadata(document_id, user_id, group_id=None, public_worksp """ settings = get_settings() - enable_gpt_apim = settings.get('enable_gpt_apim', False) enable_user_workspace = settings.get('enable_user_workspace', False) enable_group_workspaces = settings.get('enable_group_workspaces', False) @@ -3608,34 +3763,17 @@ def extract_document_metadata(document_id, user_id, group_id=None, public_worksp print(f"Error processing Hybrid search for document {document_id}: {e}") search_results = "No Hybrid results" - gpt_model = settings.get('metadata_extraction_model') - # --- Step 5: Prepare GPT Client --- - if enable_gpt_apim: - # APIM-based GPT client - gpt_client = AzureOpenAI( - api_version=settings.get('azure_apim_gpt_api_version'), - azure_endpoint=settings.get('azure_apim_gpt_endpoint'), - api_key=settings.get('azure_apim_gpt_subscription_key') + try: + gpt_client, gpt_model = _resolve_metadata_extraction_client(settings) + except Exception as e: + add_file_task_to_file_processing_log( + document_id=document_id, + user_id=group_id if is_group else user_id, + content=f"Error resolving metadata extraction model for document {document_id}: {e}" ) - else: - # Standard Azure OpenAI approach - if settings.get('azure_openai_gpt_authentication_type') == 'managed_identity': - token_provider = get_bearer_token_provider( - DefaultAzureCredential(), - cognitive_services_scope - ) - gpt_client = AzureOpenAI( - api_version=settings.get('azure_openai_gpt_api_version'), - azure_endpoint=settings.get('azure_openai_gpt_endpoint'), - azure_ad_token_provider=token_provider - ) - else: - gpt_client = AzureOpenAI( - api_version=settings.get('azure_openai_gpt_api_version'), - azure_endpoint=settings.get('azure_openai_gpt_endpoint'), - api_key=settings.get('azure_openai_gpt_key') - ) + print(f"Error resolving metadata extraction model for document {document_id}: {e}") + return meta_data # --- Step 6: GPT Prompt and JSON Parsing --- try: diff --git a/application/single_app/functions_governance.py b/application/single_app/functions_governance.py new file mode 100644 index 000000000..f3ccb7a20 --- /dev/null +++ b/application/single_app/functions_governance.py @@ -0,0 +1,888 @@ +# functions_governance.py + +"""Governance policy helpers for agents, actions, and endpoints.""" + +from copy import deepcopy +from datetime import datetime +from threading import RLock +from time import monotonic +from typing import Any, Dict, List, Optional, Set, Tuple +import uuid + +from flask import g, has_request_context + +import app_settings_cache +from config import cosmos_governance_item_policies_container, cosmos_governance_policies_container +from functions_activity_logging import log_governance_change +from functions_group import get_user_groups +from functions_public_workspaces import get_user_public_workspaces +from functions_settings import get_settings + + +DEFAULT_FEATURE_POLICIES = { + "governance_user_endpoints": "user_endpoints", + "governance_group_endpoints": "group_endpoints", + "governance_global_endpoints": "global_endpoints", + "governance_user_agents": "user_agents", + "governance_group_agents": "group_agents", + "governance_global_agents_usage": "global_agents_usage", + "governance_user_actions": "user_actions", + "governance_group_actions": "group_actions", + "governance_global_actions_usage": "global_actions_usage", +} + + +DEFAULT_ITEM_POLICY_ENTITY_TYPES = { + "global_endpoint", + "global_agent", + "global_action", +} + + +LEGACY_ITEM_POLICY_ENTITY_TYPE_ALIASES = { + "endpoint": "global_endpoint", +} + + +GOVERNANCE_CACHE_TTL_SECONDS = 60 +_GOVERNANCE_REQUEST_CACHE_ATTR = "simplechat_governance_request_cache" +_GOVERNANCE_CACHE_MISS = object() +_governance_cache_lock = RLock() +_governance_cache_version = 0 +_governance_process_cache: Dict[Any, Dict[str, Any]] = {} + + +def _clone_cache_value(value: Any) -> Any: + return deepcopy(value) + + +def _get_request_cache() -> Optional[Dict[Any, Any]]: + if not has_request_context(): + return None + + cache = getattr(g, _GOVERNANCE_REQUEST_CACHE_ATTR, None) + if cache is None: + cache = {} + setattr(g, _GOVERNANCE_REQUEST_CACHE_ATTR, cache) + return cache + + +def _get_request_cache_value(cache_key: Any) -> Any: + cache = _get_request_cache() + if cache is None or cache_key not in cache: + return _GOVERNANCE_CACHE_MISS + return _clone_cache_value(cache[cache_key]) + + +def _set_request_cache_value(cache_key: Any, value: Any) -> None: + cache = _get_request_cache() + if cache is not None: + cache[cache_key] = _clone_cache_value(value) + + +def _get_shared_governance_cache_version() -> int: + getter = getattr(app_settings_cache, "get_governance_cache_version", None) + if callable(getter): + try: + return int(getter() or 0) + except Exception: + return _governance_cache_version + return _governance_cache_version + + +def _bump_shared_governance_cache_version() -> int: + global _governance_cache_version + + bumper = getattr(app_settings_cache, "bump_governance_cache_version", None) + if callable(bumper): + try: + _governance_cache_version = int(bumper() or 0) + return _governance_cache_version + except Exception: + pass + + _governance_cache_version += 1 + return _governance_cache_version + + +def _get_process_cache_value(cache_key: Any) -> Any: + now = monotonic() + current_version = _get_shared_governance_cache_version() + with _governance_cache_lock: + entry = _governance_process_cache.get(cache_key) + if not entry: + return _GOVERNANCE_CACHE_MISS + + if entry.get("version") != current_version or entry.get("expires_at", 0) <= now: + _governance_process_cache.pop(cache_key, None) + return _GOVERNANCE_CACHE_MISS + + return _clone_cache_value(entry.get("value")) + + +def _set_process_cache_value(cache_key: Any, value: Any) -> None: + current_version = _get_shared_governance_cache_version() + with _governance_cache_lock: + _governance_process_cache[cache_key] = { + "expires_at": monotonic() + GOVERNANCE_CACHE_TTL_SECONDS, + "version": current_version, + "value": _clone_cache_value(value), + } + + +def _get_request_cached_governance_value(cache_key: Any, loader) -> Any: + cached_value = _get_request_cache_value(cache_key) + if cached_value is not _GOVERNANCE_CACHE_MISS: + return cached_value + + loaded_value = loader() + _set_request_cache_value(cache_key, loaded_value) + return loaded_value + + +def _get_cached_governance_value(cache_key: Any, loader) -> Any: + cached_value = _get_request_cache_value(cache_key) + if cached_value is not _GOVERNANCE_CACHE_MISS: + return cached_value + + cached_value = _get_process_cache_value(cache_key) + if cached_value is not _GOVERNANCE_CACHE_MISS: + _set_request_cache_value(cache_key, cached_value) + return cached_value + + loaded_value = loader() + _set_process_cache_value(cache_key, loaded_value) + _set_request_cache_value(cache_key, loaded_value) + return loaded_value + + +def invalidate_governance_cache() -> None: + _bump_shared_governance_cache_version() + + with _governance_cache_lock: + _governance_process_cache.clear() + + request_cache = _get_request_cache() + if request_cache is not None: + request_cache.clear() + + +def _normalize_str_list(values: Any) -> List[str]: + if not isinstance(values, list): + return [] + normalized: List[str] = [] + seen: Set[str] = set() + for value in values: + as_str = str(value or "").strip() + if not as_str: + continue + if as_str in seen: + continue + seen.add(as_str) + normalized.append(as_str) + return normalized + + +def _normalize_item_policy_entity_type(entity_type: str) -> str: + normalized_entity_type = str(entity_type or "").strip().lower() + return LEGACY_ITEM_POLICY_ENTITY_TYPE_ALIASES.get(normalized_entity_type, normalized_entity_type) + + +def _get_legacy_item_policy_entity_types(entity_type: str) -> List[str]: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + return [ + legacy_entity_type + for legacy_entity_type, current_entity_type in LEGACY_ITEM_POLICY_ENTITY_TYPE_ALIASES.items() + if current_entity_type == normalized_entity_type + ] + + +def _item_policy_document_id(entity_type: str, item_id: str, policy_id: str) -> str: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = str(item_id or "").strip() + normalized_policy_id = str(policy_id or "default").strip() or "default" + return f"item:{normalized_entity_type}:{normalized_item_id}:{normalized_policy_id}" + + +def _legacy_item_policy_document_id(entity_type: str, item_id: str) -> str: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = str(item_id or "").strip() + return f"item:{normalized_entity_type}:{normalized_item_id}" + + +def _extract_policy_id_from_item_doc(policy: Dict[str, Any], entity_type: str, item_id: str) -> str: + explicit_policy_id = str((policy or {}).get("policy_id") or "").strip() + if explicit_policy_id: + return explicit_policy_id + + doc_id = str((policy or {}).get("id") or "").strip() + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = str(item_id or "").strip() + prefix = f"item:{normalized_entity_type}:{normalized_item_id}:" + if doc_id.startswith(prefix): + suffix = doc_id[len(prefix):].strip() + if suffix: + return suffix + + return "default" + + +def _default_item_policy_name(entity_type: str, item_id: str, resource_label: str = "") -> str: + label = str(resource_label or "").strip() or str(item_id or "").strip() or "Resource" + entity_label = _normalize_item_policy_entity_type(entity_type).replace("_", " ").title() + return f"{label} {entity_label} Policy" + + +def _normalize_item_policy_doc(policy: Dict[str, Any]) -> Dict[str, Any]: + normalized_policy = dict(policy) + normalized_entity_type = _normalize_item_policy_entity_type(normalized_policy.get("entity_type", "")) + normalized_item_id = str(normalized_policy.get("item_id") or "").strip() + normalized_policy_id = _extract_policy_id_from_item_doc(normalized_policy, normalized_entity_type, normalized_item_id) + normalized_resource_label = str(normalized_policy.get("resource_label") or "").strip() + normalized_policy_name = str(normalized_policy.get("policy_name") or "").strip() or _default_item_policy_name( + normalized_entity_type, + normalized_item_id, + normalized_resource_label, + ) + normalized_policy["id"] = _item_policy_document_id(normalized_entity_type, normalized_item_id, normalized_policy_id) + normalized_policy["policy_id"] = normalized_policy_id + normalized_policy["policy_name"] = normalized_policy_name + normalized_policy["resource_label"] = normalized_resource_label + normalized_policy["entity_type"] = normalized_entity_type + normalized_policy["item_id"] = normalized_item_id + normalized_policy.update(_normalize_policy_state(normalized_policy)) + return normalized_policy + + +def _extract_group_ids(group_docs: Any) -> List[str]: + if not isinstance(group_docs, list): + return [] + group_ids: List[str] = [] + seen: Set[str] = set() + for group_doc in group_docs: + group_id = str((group_doc or {}).get("id") or "").strip() + if not group_id or group_id in seen: + continue + seen.add(group_id) + group_ids.append(group_id) + return group_ids + + +def _extract_workspace_ids(workspace_docs: Any) -> List[str]: + if not isinstance(workspace_docs, list): + return [] + workspace_ids: List[str] = [] + seen: Set[str] = set() + for workspace_doc in workspace_docs: + workspace_id = str((workspace_doc or {}).get("id") or "").strip() + if not workspace_id or workspace_id in seen: + continue + seen.add(workspace_id) + workspace_ids.append(workspace_id) + return workspace_ids + + +def _default_feature_policy_doc(feature_key: str) -> Dict[str, Any]: + return { + "id": f"feature:{feature_key}", + "feature_key": feature_key, + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + "updated_at": datetime.utcnow().isoformat(), + } + + +def _default_item_policy_doc( + entity_type: str, + item_id: str, + policy_id: str = "default", + policy_name: str = "", + resource_label: str = "", +) -> Dict[str, Any]: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = str(item_id or "").strip() + normalized_policy_id = str(policy_id or "default").strip() or "default" + normalized_resource_label = str(resource_label or "").strip() + normalized_policy_name = str(policy_name or "").strip() or _default_item_policy_name( + normalized_entity_type, + normalized_item_id, + normalized_resource_label, + ) + return { + "id": _item_policy_document_id(normalized_entity_type, normalized_item_id, normalized_policy_id), + "policy_id": normalized_policy_id, + "policy_name": normalized_policy_name, + "resource_label": normalized_resource_label, + "entity_type": normalized_entity_type, + "item_id": normalized_item_id, + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + "updated_at": datetime.utcnow().isoformat(), + } + + +def _normalize_policy_state(payload: Dict[str, Any]) -> Dict[str, Any]: + allow_all = bool(payload.get("allow_all", True)) + allowed_users = _normalize_str_list(payload.get("allowed_users", [])) + allowed_groups = _normalize_str_list(payload.get("allowed_groups", [])) + + if allow_all and (allowed_users or allowed_groups): + allow_all = False + + if allow_all: + allowed_users = [] + allowed_groups = [] + + return { + "allow_all": allow_all, + "allowed_users": allowed_users, + "allowed_groups": allowed_groups, + } + + +def _read_feature_policy(feature_key: str) -> Dict[str, Any]: + try: + return _read_stored_feature_policy(feature_key) + except Exception: + return _default_feature_policy_doc(feature_key) + + +def _read_stored_feature_policy(feature_key: str) -> Dict[str, Any]: + policy_id = f"feature:{feature_key}" + return cosmos_governance_policies_container.read_item(item=policy_id, partition_key=policy_id) + + +def _read_item_policies(entity_type: str, item_id: str) -> List[Dict[str, Any]]: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = str(item_id or "").strip() + if not normalized_item_id: + return [_default_item_policy_doc(normalized_entity_type, normalized_item_id)] + + candidate_entity_types = [normalized_entity_type] + _get_legacy_item_policy_entity_types(normalized_entity_type) + rows = [] + query = "SELECT * FROM c WHERE c.entity_type = @entity_type AND c.item_id = @item_id" + for candidate_entity_type in candidate_entity_types: + parameters = [ + {"name": "@entity_type", "value": candidate_entity_type}, + {"name": "@item_id", "value": normalized_item_id}, + ] + try: + rows.extend(list(cosmos_governance_item_policies_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + ))) + except Exception: + pass + + try: + legacy_policy = _read_stored_item_policy(normalized_entity_type, normalized_item_id) + rows.append(legacy_policy) + except Exception: + pass + + for legacy_entity_type in _get_legacy_item_policy_entity_types(normalized_entity_type): + try: + rows.append(_read_stored_item_policy(legacy_entity_type, normalized_item_id)) + except Exception: + pass + + normalized_rows_by_key: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + for row in rows: + if not isinstance(row, dict): + continue + normalized_row = _normalize_item_policy_doc(row) + row_key = ( + str(normalized_row.get("entity_type") or ""), + str(normalized_row.get("item_id") or ""), + str(normalized_row.get("policy_id") or ""), + ) + normalized_rows_by_key[row_key] = normalized_row + + normalized_rows = list(normalized_rows_by_key.values()) + if not normalized_rows: + return [_default_item_policy_doc(normalized_entity_type, normalized_item_id)] + + return sorted(normalized_rows, key=lambda item: (str(item.get("policy_name") or ""), str(item.get("policy_id") or ""))) + + +def _read_item_policy(entity_type: str, item_id: str) -> Dict[str, Any]: + return _read_item_policies(entity_type, item_id)[0] + + +def _read_stored_item_policy(entity_type: str, item_id: str) -> Dict[str, Any]: + normalized_item_id = str(item_id or "").strip() + policy_id = _legacy_item_policy_document_id(entity_type, normalized_item_id) + return cosmos_governance_item_policies_container.read_item(item=policy_id, partition_key=policy_id) + + +def get_feature_policy(feature_key: str) -> Dict[str, Any]: + normalized_feature_key = str(feature_key or "").strip() + + def load_policy() -> Dict[str, Any]: + policy = dict(_read_feature_policy(normalized_feature_key)) + normalized = _normalize_policy_state(policy) + policy.update(normalized) + return policy + + return _get_cached_governance_value(("feature_policy", normalized_feature_key), load_policy) + + +def get_item_policy(entity_type: str, item_id: str) -> Dict[str, Any]: + return get_item_policies(entity_type, item_id)[0] + + +def get_item_policies(entity_type: str, item_id: str) -> List[Dict[str, Any]]: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = str(item_id or "").strip() + + def load_policies() -> List[Dict[str, Any]]: + policies = [] + for policy in _read_item_policies(normalized_entity_type, normalized_item_id): + normalized_policy = dict(policy) + normalized = _normalize_policy_state(normalized_policy) + normalized_policy.update(normalized) + policies.append(normalized_policy) + return policies + + return _get_cached_governance_value( + ("item_policies", normalized_entity_type, normalized_item_id), + load_policies, + ) + + +def _build_diff(before_doc: Dict[str, Any], after_doc: Dict[str, Any]) -> Dict[str, Any]: + before_users = set(_normalize_str_list(before_doc.get("allowed_users", []))) + after_users = set(_normalize_str_list(after_doc.get("allowed_users", []))) + before_groups = set(_normalize_str_list(before_doc.get("allowed_groups", []))) + after_groups = set(_normalize_str_list(after_doc.get("allowed_groups", []))) + + return { + "allow_all": { + "before": bool(before_doc.get("allow_all", True)), + "after": bool(after_doc.get("allow_all", True)), + }, + "users_added": sorted(list(after_users - before_users)), + "users_removed": sorted(list(before_users - after_users)), + "groups_added": sorted(list(after_groups - before_groups)), + "groups_removed": sorted(list(before_groups - after_groups)), + } + + +def upsert_feature_policy( + feature_key: str, + payload: Dict[str, Any], + actor_user_id: str, + actor_email: str, +) -> Dict[str, Any]: + before_policy = _read_feature_policy(feature_key) + policy_id = f"feature:{feature_key}" + normalized_payload = _normalize_policy_state(payload) + after_policy = { + "id": policy_id, + "feature_key": feature_key, + "allow_all": normalized_payload["allow_all"], + "allowed_users": normalized_payload["allowed_users"], + "allowed_groups": normalized_payload["allowed_groups"], + "updated_by": str(actor_user_id or "").strip(), + "updated_at": datetime.utcnow().isoformat(), + } + + stored = cosmos_governance_policies_container.upsert_item(body=after_policy) + invalidate_governance_cache() + + log_governance_change( + admin_user_id=str(actor_user_id or "").strip(), + admin_email=str(actor_email or "").strip(), + action="feature_policy_upsert", + scope="feature", + target_id=feature_key, + before_state=before_policy, + after_state=stored, + change_details=_build_diff(before_policy, stored), + ) + + return stored + + +def upsert_item_policy( + entity_type: str, + item_id: str, + payload: Dict[str, Any], + actor_user_id: str, + actor_email: str, +) -> Dict[str, Any]: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = str(item_id or "").strip() + incoming_policy_id = str((payload or {}).get("policy_id") or "").strip() + normalized_policy_id = incoming_policy_id or str(uuid.uuid4()) + policy_id = _item_policy_document_id(normalized_entity_type, normalized_item_id, normalized_policy_id) + before_policy = next( + ( + policy for policy in _read_item_policies(normalized_entity_type, normalized_item_id) + if str(policy.get("policy_id") or "") == normalized_policy_id + ), + _default_item_policy_doc(normalized_entity_type, normalized_item_id, normalized_policy_id), + ) + normalized_payload = _normalize_policy_state(payload) + normalized_resource_label = str((payload or {}).get("resource_label") or "").strip() + normalized_policy_name = str((payload or {}).get("policy_name") or "").strip() or _default_item_policy_name( + normalized_entity_type, + normalized_item_id, + normalized_resource_label, + ) + + after_policy = { + "id": policy_id, + "policy_id": normalized_policy_id, + "policy_name": normalized_policy_name, + "resource_label": normalized_resource_label, + "entity_type": normalized_entity_type, + "item_id": normalized_item_id, + "allow_all": normalized_payload["allow_all"], + "allowed_users": normalized_payload["allowed_users"], + "allowed_groups": normalized_payload["allowed_groups"], + "updated_by": str(actor_user_id or "").strip(), + "updated_at": datetime.utcnow().isoformat(), + } + + stored = cosmos_governance_item_policies_container.upsert_item(body=after_policy) + legacy_current_policy_id = _legacy_item_policy_document_id(normalized_entity_type, normalized_item_id) + if legacy_current_policy_id != policy_id: + try: + cosmos_governance_item_policies_container.delete_item( + item=legacy_current_policy_id, + partition_key=legacy_current_policy_id, + ) + except Exception: + pass + + for legacy_entity_type in _get_legacy_item_policy_entity_types(normalized_entity_type): + legacy_policy_id = _legacy_item_policy_document_id(legacy_entity_type, normalized_item_id) + try: + cosmos_governance_item_policies_container.delete_item( + item=legacy_policy_id, + partition_key=legacy_policy_id, + ) + except Exception: + pass + + invalidate_governance_cache() + + log_governance_change( + admin_user_id=str(actor_user_id or "").strip(), + admin_email=str(actor_email or "").strip(), + action="item_policy_upsert", + scope=normalized_entity_type, + target_id=normalized_item_id, + before_state=before_policy, + after_state=stored, + change_details=_build_diff(before_policy, stored), + ) + + return stored + + +def _read_existing_item_policy_for_delete(entity_type: str, item_id: str, policy_id: Optional[str] = None) -> Tuple[Dict[str, Any], str, str]: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = str(item_id or "").strip() + normalized_policy_id = str(policy_id or "").strip() + candidate_entity_types = [normalized_entity_type] + _get_legacy_item_policy_entity_types(normalized_entity_type) + + if normalized_policy_id: + for candidate_entity_type in candidate_entity_types: + document_id = _item_policy_document_id(candidate_entity_type, normalized_item_id, normalized_policy_id) + try: + return cosmos_governance_item_policies_container.read_item(item=document_id, partition_key=document_id), candidate_entity_type, document_id + except Exception: + pass + + if normalized_policy_id in ("", "default"): + for candidate_entity_type in candidate_entity_types: + legacy_document_id = _legacy_item_policy_document_id(candidate_entity_type, normalized_item_id) + try: + return cosmos_governance_item_policies_container.read_item(item=legacy_document_id, partition_key=legacy_document_id), candidate_entity_type, legacy_document_id + except Exception: + pass + + last_exception = None + for candidate_entity_type in candidate_entity_types: + try: + policies = _read_item_policies(candidate_entity_type, normalized_item_id) + if policies: + policy = policies[0] + document_id = str(policy.get("id") or _item_policy_document_id(candidate_entity_type, normalized_item_id, policy.get("policy_id") or "default")) + return policy, candidate_entity_type, document_id + except Exception as ex: + last_exception = ex + + if last_exception: + raise last_exception + raise ValueError("Item governance policy not found.") + + +def delete_item_policy( + entity_type: str, + item_id: str, + actor_user_id: str, + actor_email: str, + policy_id: Optional[str] = None, +) -> Dict[str, Any]: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + normalized_item_id = str(item_id or "").strip() + stored_policy, stored_entity_type, document_id = _read_existing_item_policy_for_delete(normalized_entity_type, normalized_item_id, policy_id) + before_policy = _normalize_item_policy_doc(stored_policy) + + cosmos_governance_item_policies_container.delete_item( + item=document_id, + partition_key=document_id, + ) + invalidate_governance_cache() + + after_policy = _default_item_policy_doc(normalized_entity_type, normalized_item_id, before_policy.get("policy_id") or "default") + log_governance_change( + admin_user_id=str(actor_user_id or "").strip(), + admin_email=str(actor_email or "").strip(), + action="item_policy_delete", + scope=normalized_entity_type, + target_id=normalized_item_id, + before_state=before_policy, + after_state=after_policy, + change_details=_build_diff(before_policy, after_policy), + ) + + return before_policy + + +def list_feature_policies() -> List[Dict[str, Any]]: + query = "SELECT * FROM c" + rows = list(cosmos_governance_policies_container.query_items(query=query, enable_cross_partition_query=True)) + + rows_by_feature_key = { + str(row.get("feature_key") or "").strip(): row + for row in rows + if isinstance(row, dict) and str(row.get("feature_key") or "").strip() + } + + normalized_rows = [] + seen_feature_keys = set() + + for feature_key in DEFAULT_FEATURE_POLICIES.keys(): + row = rows_by_feature_key.get(feature_key) or _default_feature_policy_doc(feature_key) + normalized_row = dict(row) + normalized_row["allowed_users"] = _normalize_str_list(normalized_row.get("allowed_users", [])) + normalized_row["allowed_groups"] = _normalize_str_list(normalized_row.get("allowed_groups", [])) + normalized_row["allow_all"] = bool(normalized_row.get("allow_all", True)) + normalized_rows.append(normalized_row) + + seen_feature_keys.add(feature_key) + + for row in rows: + feature_key = str((row or {}).get("feature_key") or "").strip() + if not feature_key or feature_key in seen_feature_keys: + continue + normalized_row = dict(row) + normalized_row["allowed_users"] = _normalize_str_list(normalized_row.get("allowed_users", [])) + normalized_row["allowed_groups"] = _normalize_str_list(normalized_row.get("allowed_groups", [])) + normalized_row["allow_all"] = bool(normalized_row.get("allow_all", True)) + normalized_rows.append(normalized_row) + seen_feature_keys.add(feature_key) + + return sorted(normalized_rows, key=lambda item: str(item.get("feature_key") or "")) + + +def list_item_policies(entity_type: Optional[str] = None) -> List[Dict[str, Any]]: + query_entity_types = [] + if entity_type: + normalized_entity_type = _normalize_item_policy_entity_type(entity_type) + query_entity_types = [normalized_entity_type] + _get_legacy_item_policy_entity_types(normalized_entity_type) + query = "SELECT * FROM c WHERE c.entity_type = @entity_type" + rows = [] + for query_entity_type in query_entity_types: + parameters = [{"name": "@entity_type", "value": query_entity_type}] + rows.extend( + list( + cosmos_governance_item_policies_container.query_items( + query=query, + parameters=parameters, + enable_cross_partition_query=True, + ) + ) + ) + else: + query = "SELECT * FROM c" + rows = list( + cosmos_governance_item_policies_container.query_items( + query=query, + enable_cross_partition_query=True, + ) + ) + + normalized_rows_by_key = {} + for row in rows: + stored_entity_type = str((row or {}).get("entity_type") or "").strip().lower() + normalized_row = _normalize_item_policy_doc(row) + normalized_entity_type = str(normalized_row.get("entity_type") or "") + normalized_item_id = str(normalized_row.get("item_id") or "") + normalized_policy_id = str(normalized_row.get("policy_id") or "") + row_key = (normalized_entity_type, normalized_item_id, normalized_policy_id) + existing_row = normalized_rows_by_key.get(row_key) + if not existing_row or stored_entity_type == normalized_entity_type: + normalized_rows_by_key[row_key] = normalized_row + + normalized_rows = list(normalized_rows_by_key.values()) + return sorted( + normalized_rows, + key=lambda item: ( + str(item.get("entity_type") or ""), + str(item.get("resource_label") or item.get("item_id") or ""), + str(item.get("policy_name") or ""), + ), + ) + + +def get_user_governance_group_ids(user_id: str) -> Set[str]: + normalized_user_id = str(user_id or "").strip() + if not normalized_user_id: + return set() + + def load_group_ids() -> Set[str]: + group_ids = set() + + try: + user_groups = get_user_groups(normalized_user_id) + group_ids.update(_extract_group_ids(user_groups)) + except Exception: + pass + + try: + user_workspaces = get_user_public_workspaces(normalized_user_id) + group_ids.update(_extract_workspace_ids(user_workspaces)) + except Exception: + pass + + return group_ids + + return _get_cached_governance_value(("user_governance_group_ids", normalized_user_id), load_group_ids) + + +def _passes_policy(policy: Dict[str, Any], user_id: str, group_ids: Set[str]) -> bool: + if bool(policy.get("allow_all", True)): + return True + + allowed_users = set(_normalize_str_list(policy.get("allowed_users", []))) + allowed_groups = set(_normalize_str_list(policy.get("allowed_groups", []))) + + if not allowed_users and not allowed_groups: + return True + + normalized_user_id = str(user_id or "").strip() + if normalized_user_id and normalized_user_id in allowed_users: + return True + + if group_ids.intersection(allowed_groups): + return True + + return False + + +def ensure_governance_access( + feature_key: str, + user_id: str, + item_entity_type: Optional[str] = None, + item_id: Optional[str] = None, +) -> None: + normalized_feature_key = str(feature_key or "").strip() + normalized_user_id = str(user_id or "").strip() + normalized_item_entity_type = _normalize_item_policy_entity_type(item_entity_type or "") + normalized_item_id = str(item_id or "").strip() + decision_key = ( + "governance_access_decision", + normalized_feature_key, + normalized_user_id, + normalized_item_entity_type, + normalized_item_id, + ) + + cached_decision = _get_request_cache_value(decision_key) + if cached_decision is True: + return + + settings = _get_request_cached_governance_value(("settings",), get_settings) + if not bool((settings or {}).get(normalized_feature_key, False)): + _set_request_cache_value(decision_key, True) + return + + user_group_ids = get_user_governance_group_ids(normalized_user_id) + + feature_policy = get_feature_policy(normalized_feature_key) + if not _passes_policy(feature_policy, normalized_user_id, user_group_ids): + raise PermissionError(f"Governance policy blocks access for feature '{feature_key}'.") + + if normalized_item_entity_type and normalized_item_id: + item_policies = get_item_policies(normalized_item_entity_type, normalized_item_id) + if not any(_passes_policy(item_policy, normalized_user_id, user_group_ids) for item_policy in item_policies): + raise PermissionError( + f"Governance policy blocks access to {item_entity_type} '{item_id}'." + ) + + _set_request_cache_value(decision_key, True) + + +def is_governance_access_allowed( + feature_key: str, + user_id: str, + item_entity_type: Optional[str] = None, + item_id: Optional[str] = None, +) -> bool: + try: + ensure_governance_access(feature_key, user_id, item_entity_type, item_id) + return True + except PermissionError: + return False + + +def filter_governed_model_endpoints( + user_id: str, + endpoints: Any, + feature_key: str, +) -> List[Dict[str, Any]]: + if not is_governance_access_allowed(feature_key, user_id): + return [] + + governed_endpoints = [] + for endpoint in endpoints or []: + if not isinstance(endpoint, dict): + continue + + endpoint_id = str(endpoint.get("id") or "").strip() + if endpoint_id and not is_governance_access_allowed( + feature_key, + user_id, + item_entity_type="global_endpoint", + item_id=endpoint_id, + ): + continue + + governed_endpoints.append(endpoint) + + return governed_endpoints + + +def bootstrap_default_feature_policies() -> None: + created_defaults = False + for feature_key in DEFAULT_FEATURE_POLICIES.keys(): + try: + _read_stored_feature_policy(feature_key) + continue + except Exception: + pass + + default_policy = _default_feature_policy_doc(feature_key) + cosmos_governance_policies_container.upsert_item(body=default_policy) + created_defaults = True + + if created_defaults: + invalidate_governance_cache() diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py index 450d34e59..7ed0685b1 100644 --- a/application/single_app/functions_group_actions.py +++ b/application/single_app/functions_group_actions.py @@ -17,6 +17,7 @@ keyvault_plugin_get_helper, keyvault_plugin_save_helper, ) +from functions_governance import ensure_governance_access _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -84,6 +85,9 @@ def get_group_action( def save_group_action(group_id: str, action_data: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: """Create or update a group action entry.""" + if user_id: + ensure_governance_access('governance_group_actions', user_id) + payload = dict(action_data) action_id = payload.get("id") or str(uuid.uuid4()) diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py index ae79309bc..ec8216e35 100644 --- a/application/single_app/functions_group_agents.py +++ b/application/single_app/functions_group_agents.py @@ -17,6 +17,7 @@ keyvault_agent_save_helper, ) from functions_agent_payload import sanitize_agent_payload +from functions_governance import ensure_governance_access _NAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") @@ -65,6 +66,9 @@ def get_group_agent(group_id: str, agent_id: str) -> Optional[Dict[str, Any]]: def save_group_agent(group_id: str, agent_data: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: """Create or update a group agent entry.""" + if user_id: + ensure_governance_access('governance_group_agents', user_id) + payload = sanitize_agent_payload(agent_data) agent_id = payload.get("id") or str(uuid.uuid4()) payload["id"] = agent_id diff --git a/application/single_app/functions_personal_actions.py b/application/single_app/functions_personal_actions.py index 56d5e36fb..e67ca889d 100644 --- a/application/single_app/functions_personal_actions.py +++ b/application/single_app/functions_personal_actions.py @@ -16,6 +16,7 @@ from functions_debug import debug_print from config import cosmos_personal_actions_container import logging +from functions_governance import ensure_governance_access def get_personal_actions(user_id, return_type=SecretReturnType.TRIGGER): """ @@ -107,6 +108,7 @@ def save_personal_action(user_id, action_data): dict: Saved action data with ID """ try: + ensure_governance_access('governance_user_actions', user_id) # Check if an action with this name already exists existing_action = None if action_data.get('id'): diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index d6720d103..63fc38528 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -20,6 +20,7 @@ from functions_keyvault import keyvault_agent_save_helper, keyvault_agent_get_helper, keyvault_agent_delete_helper from functions_agent_payload import sanitize_agent_payload from functions_debug import debug_print +from functions_governance import ensure_governance_access def get_personal_agents(user_id): """ @@ -118,6 +119,7 @@ def save_personal_agent(user_id, agent_data, actor_user_id=None): dict: Saved agent data with ID """ try: + ensure_governance_access('governance_user_agents', user_id) modifying_user_id = actor_user_id or user_id cleaned_agent = sanitize_agent_payload(agent_data) for field in ['name', 'display_name', 'description', 'instructions']: diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 0091d0a38..8f5a65e07 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -66,6 +66,45 @@ def is_tabular_processing_enabled(settings): """Tabular processing is available whenever enhanced citations is enabled.""" return bool((settings or {}).get('enable_enhanced_citations', False)) + +def _refresh_app_settings_cache_after_write(settings_payload, context="app_settings_write"): + """Update shared/local settings cache around a version bump.""" + cache_updater = getattr(app_settings_cache, "update_settings_cache", None) + version_bumper = getattr(app_settings_cache, "bump_app_settings_cache_version", None) + + def _update_cache(stage): + if not callable(cache_updater): + return + try: + cache_updater(copy.deepcopy(settings_payload)) + except Exception as cache_error: + log_event( + "App settings cache update failed after settings write.", + extra={ + "context": context, + "stage": stage, + "error": str(cache_error) + }, + level=logging.WARNING + ) + + _update_cache("before_version_bump") + + if callable(version_bumper): + try: + version_bumper() + except Exception as version_error: + log_event( + "App settings cache version bump failed after settings write.", + extra={ + "context": context, + "error": str(version_error) + }, + level=logging.WARNING + ) + + _update_cache("after_version_bump") + def get_settings(use_cosmos=False, include_source=False): import secrets default_settings = { @@ -105,6 +144,15 @@ def get_settings(use_cosmos=False, include_source=False): 'allow_group_agents': False, 'allow_group_custom_endpoints': False, 'allow_group_custom_agent_endpoints': False, + 'governance_user_endpoints': False, + 'governance_group_endpoints': False, + 'governance_global_endpoints': True, + 'governance_user_agents': False, + 'governance_group_agents': False, + 'governance_global_agents_usage': False, + 'governance_user_actions': False, + 'governance_group_actions': False, + 'governance_global_actions_usage': False, 'allow_ai_foundry_agents': False, 'allow_group_ai_foundry_agents': False, 'allow_personal_ai_foundry_agents': False, @@ -230,6 +278,11 @@ def get_settings(use_cosmos=False, include_source=False): # Metadata Extraction 'enable_extract_meta_data': False, 'metadata_extraction_model': '', + 'metadata_extraction_model_selection': { + 'endpoint_id': '', + 'model_id': '', + 'provider': '' + }, # Multimodal Vision 'enable_multimodal_vision': False, @@ -260,6 +313,11 @@ def get_settings(use_cosmos=False, include_source=False): {"label": "Prompt Ideas", "url": "https://example.com/prompts"} ], + # Custom Pages + 'enable_custom_pages': False, + 'custom_pages_menu_name': 'Custom Pages', + 'custom_pages_force_menu': False, + # Support Menu 'enable_support_menu': False, 'support_menu_name': 'Support', @@ -386,6 +444,9 @@ def get_settings(use_cosmos=False, include_source=False): # Access denied message shown on the home page for signed-in users who lack required roles. # Default is hard-coded; admins can override via Admin Settings (persisted in Cosmos DB). 'access_denied_message': 'You are logged in but do not have the required permissions to access this application.\nPlease contact an administrator for access.', + 'access_request_button_enabled': False, + 'access_request_button_text': 'Request Access', + 'access_request_page_url': '/custom/request-access', 'enable_file_processing_logs': True, 'file_processing_logs_timer_enabled': False, 'file_timer_value': 1, @@ -536,19 +597,7 @@ def _format_result(settings_payload, source): # If merging added anything new, upsert back to Cosmos so future reads remain up to date if merge_changed or migration_updated: cosmos_settings_container.upsert_item(merged) - cache_updater = getattr(app_settings_cache, "update_settings_cache", None) - if callable(cache_updater): - try: - cache_updater(copy.deepcopy(merged)) - except Exception as cache_error: - log_event( - "App settings cache update failed after merge upsert.", - extra={ - "settings_source": settings_source, - "error": str(cache_error) - }, - level=logging.WARNING - ) + _refresh_app_settings_cache_after_write(merged, context="merge_upsert") log_event( "App settings missing keys were merged and persisted to Cosmos DB.", @@ -564,6 +613,7 @@ def _format_result(settings_payload, source): except CosmosResourceNotFoundError: cosmos_settings_container.create_item(body=default_settings) + _refresh_app_settings_cache_after_write(default_settings, context="default_create") log_event( "App settings document not found. Default settings created in Cosmos DB.", @@ -598,9 +648,7 @@ def update_settings(new_settings): ) settings_item['enable_tabular_processing_plugin'] = is_tabular_processing_enabled(settings_item) cosmos_settings_container.upsert_item(settings_item) - cache_updater = getattr(app_settings_cache, "update_settings_cache", None) - if callable(cache_updater): - cache_updater(settings_item) + _refresh_app_settings_cache_after_write(settings_item, context="update_settings") log_event( "App settings updated successfully.", level=logging.INFO diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 561f1d181..db32c00ba 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -42,10 +42,29 @@ log_agent_deletion, log_general_admin_action, ) +from functions_governance import ensure_governance_access, upsert_item_policy bpa = Blueprint('admin_agents', __name__) +def _is_agent_allowed_for_user_selection(user_id, agent): + try: + if agent.get('is_global'): + ensure_governance_access( + 'governance_global_agents_usage', + user_id, + item_entity_type='global_agent', + item_id=str(agent.get('id') or agent.get('name') or ''), + ) + elif agent.get('is_group'): + ensure_governance_access('governance_group_agents', user_id) + else: + ensure_governance_access('governance_user_agents', user_id) + return True + except PermissionError: + return False + + def _build_user_selectable_agents(user_id, requested_agent=None): """Build the set of agents the current user is allowed to select.""" settings = get_settings() @@ -545,6 +564,8 @@ def get_user_agents(): agent['is_group'] = False agent.setdefault('agent_type', 'local') + agents = [agent for agent in agents if _is_agent_allowed_for_user_selection(user_id, agent)] + # Check global/merge toggles per_user = settings.get('per_user_semantic_kernel', False) merge_global = settings.get('merge_global_semantic_kernel_with_workspace', False) @@ -556,6 +577,8 @@ def get_user_agents(): agent['is_global'] = True agent['is_group'] = False agent.setdefault('agent_type', 'local') + + global_agents = [agent for agent in global_agents if _is_agent_allowed_for_user_selection(user_id, agent)] # Merge agents using ID as key to avoid name conflicts # This allows both personal and global agents with same name to coexist @@ -626,6 +649,11 @@ def set_user_agents(): current_agent_names = set(agent['name'] for agent in current_agents) # Save new/updated agents to personal_agents container + try: + ensure_governance_access('governance_user_agents', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + for agent in filtered_agents: save_personal_agent(user_id, agent) @@ -714,6 +742,12 @@ def get_group_agents_route(): return jsonify({'error': str(exc)}), 403 agents = get_group_agents(active_group) + for agent in agents: + if isinstance(agent, dict): + agent['is_global'] = False + agent['is_group'] = True + agent['group_id'] = active_group + agents = [agent for agent in agents if isinstance(agent, dict) and _is_agent_allowed_for_user_selection(user_id, agent)] return jsonify({'agents': agents}), 200 @@ -789,6 +823,8 @@ def create_group_agent_route(): try: saved = save_group_agent(active_group, cleaned_payload, user_id=user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as exc: debug_print('Failed to save group agent: %s', exc) return jsonify({'error': 'Unable to save agent'}), 500 @@ -858,6 +894,8 @@ def update_group_agent_route(agent_id): try: saved = save_group_agent(active_group, cleaned_payload, user_id=user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as exc: debug_print('Failed to update group agent %s: %s', agent_id, exc) return jsonify({'error': 'Unable to update agent'}), 500 @@ -917,6 +955,21 @@ def set_user_selected_agent(): if not matched_agent: return jsonify({'error': 'Selected agent is not available for this user or scope.'}), 400 + try: + if matched_agent.get('is_global'): + ensure_governance_access( + 'governance_global_agents_usage', + user_id, + item_entity_type='global_agent', + item_id=str(matched_agent.get('id') or matched_agent.get('name') or ''), + ) + elif matched_agent.get('is_group'): + ensure_governance_access('governance_group_agents', user_id) + else: + ensure_governance_access('governance_user_agents', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + user_settings = get_user_settings(user_id) settings_to_update = user_settings.get('settings', {}) agent_name = (matched_agent.get('name') or '').strip() @@ -1201,6 +1254,8 @@ def add_agent(): except AgentPayloadError as exc: log_event("Add agent failed: payload error", level=logging.WARNING, extra={"action": "add", "error": str(exc)}) return jsonify({'error': str(exc)}), 400 + + governance_policy_payload = cleaned_agent.pop('governance_policy', None) cleaned_agent['is_global'] = True cleaned_agent['is_group'] = False validation_error = validate_agent(cleaned_agent) @@ -1224,6 +1279,15 @@ def add_agent(): if not result: return jsonify({'error': 'Failed to save agent.'}), 500 + if isinstance(governance_policy_payload, dict): + upsert_item_policy( + entity_type='global_agent', + item_id=str(cleaned_agent.get('id') or ''), + payload=governance_policy_payload, + actor_user_id=str(get_current_user_id() or ''), + actor_email=str((session.get('user') or {}).get('email') or ''), + ) + log_agent_creation(user_id=str(get_current_user_id()), agent_id=cleaned_agent.get('id', ''), agent_name=cleaned_agent.get('name', ''), agent_display_name=cleaned_agent.get('display_name', ''), scope='global') log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- @@ -1307,6 +1371,8 @@ def edit_agent(agent_name): except AgentPayloadError as exc: log_event("Edit agent failed: payload error", level=logging.WARNING, extra={"action": "edit", "agent_name": agent_name, "error": str(exc)}) return jsonify({'error': str(exc)}), 400 + + governance_policy_payload = cleaned_agent.pop('governance_policy', None) cleaned_agent['is_global'] = True cleaned_agent['is_group'] = False validation_error = validate_agent(cleaned_agent) @@ -1336,6 +1402,15 @@ def edit_agent(agent_name): if not result: return jsonify({'error': 'Failed to save agent.'}), 500 + if isinstance(governance_policy_payload, dict): + upsert_item_policy( + entity_type='global_agent', + item_id=str(cleaned_agent.get('id') or ''), + payload=governance_policy_payload, + actor_user_id=str(get_current_user_id() or ''), + actor_email=str((session.get('user') or {}).get('email') or ''), + ) + log_agent_update(user_id=str(get_current_user_id()), agent_id=cleaned_agent.get('id', ''), agent_name=agent_name, agent_display_name=cleaned_agent.get('display_name', ''), scope='global') log_event( f"Agent {agent_name} edited", @@ -1457,20 +1532,28 @@ def build_combined_model_endpoints(settings, user_id=None, group_id=None): if group_id: if allow_group_custom_endpoints: - group_endpoints = get_group_model_endpoints(group_id) - for endpoint in group_endpoints: - enriched = dict(endpoint) - enriched["scope"] = "group" - enriched["group_id"] = group_id - endpoints.append(enriched) + try: + ensure_governance_access("governance_group_endpoints", user_id) + group_endpoints = get_group_model_endpoints(group_id) + for endpoint in group_endpoints: + enriched = dict(endpoint) + enriched["scope"] = "group" + enriched["group_id"] = group_id + endpoints.append(enriched) + except PermissionError: + pass elif user_id: if allow_user_custom_endpoints: - user_settings = get_user_settings(user_id) - personal = user_settings.get("settings", {}).get("personal_model_endpoints", []) - for endpoint in personal: - enriched = dict(endpoint) - enriched["scope"] = "user" - endpoints.append(enriched) + try: + ensure_governance_access("governance_user_endpoints", user_id) + user_settings = get_user_settings(user_id) + personal = user_settings.get("settings", {}).get("personal_model_endpoints", []) + for endpoint in personal: + enriched = dict(endpoint) + enriched["scope"] = "user" + endpoints.append(enriched) + except PermissionError: + pass return sanitize_model_endpoints_for_frontend(endpoints) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index 4d31db45f..634d72b50 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -39,6 +39,7 @@ from functions_conversation_metadata import collect_conversation_metadata, update_conversation_with_metadata from functions_conversation_unread import mark_conversation_unread from functions_debug import debug_print +from functions_governance import ensure_governance_access from functions_notifications import create_chat_response_notification from functions_activity_logging import log_chat_activity, log_conversation_creation, log_token_usage from flask import current_app @@ -5819,41 +5820,63 @@ def get_streaming_model_endpoint_candidates(settings, user_id, active_group_ids= user_settings = user_settings_doc.get('settings', {}) if isinstance(user_settings_doc, dict) else {} if settings.get('allow_user_custom_endpoints', False): - personal_endpoints, _ = normalize_model_endpoints(user_settings.get('personal_model_endpoints', []) or []) - endpoints.extend([ - {**endpoint, '_endpoint_scope': 'user'} - for endpoint in personal_endpoints - if isinstance(endpoint, dict) - ]) + try: + ensure_governance_access('governance_user_endpoints', user_id) + personal_endpoints, _ = normalize_model_endpoints(user_settings.get('personal_model_endpoints', []) or []) + endpoints.extend([ + {**endpoint, '_endpoint_scope': 'user'} + for endpoint in personal_endpoints + if isinstance(endpoint, dict) + ]) + except PermissionError: + debug_print('[Streaming][Model Resolution] User endpoint governance policy denied access to personal endpoints.') if settings.get('allow_group_custom_endpoints', False): - seen_group_ids = set() - for group_id in active_group_ids: - group_key = str(group_id or '').strip() - if not group_key or group_key in seen_group_ids: - continue - seen_group_ids.add(group_key) + try: + ensure_governance_access('governance_group_endpoints', user_id) + seen_group_ids = set() + for group_id in active_group_ids: + group_key = str(group_id or '').strip() + if not group_key or group_key in seen_group_ids: + continue + seen_group_ids.add(group_key) - try: - group_endpoints, _ = normalize_model_endpoints(get_group_model_endpoints(group_key) or []) - except Exception as group_error: - debug_print( - f"[Streaming][Model Resolution] Failed to load group endpoints for group_id={group_key}: {group_error}" - ) - continue + try: + group_endpoints, _ = normalize_model_endpoints(get_group_model_endpoints(group_key) or []) + except Exception as group_error: + debug_print( + f"[Streaming][Model Resolution] Failed to load group endpoints for group_id={group_key}: {group_error}" + ) + continue - endpoints.extend([ - {**endpoint, '_endpoint_scope': 'group'} - for endpoint in group_endpoints - if isinstance(endpoint, dict) - ]) + endpoints.extend([ + {**endpoint, '_endpoint_scope': 'group'} + for endpoint in group_endpoints + if isinstance(endpoint, dict) + ]) + except PermissionError: + debug_print('[Streaming][Model Resolution] Group endpoint governance policy denied access to group endpoints.') - global_endpoints, _ = normalize_model_endpoints(settings.get('model_endpoints', []) or []) - endpoints.extend([ - {**endpoint, '_endpoint_scope': 'global'} - for endpoint in global_endpoints - if isinstance(endpoint, dict) - ]) + try: + ensure_governance_access('governance_global_endpoints', user_id) + global_endpoints, _ = normalize_model_endpoints(settings.get('model_endpoints', []) or []) + for endpoint in global_endpoints: + if not isinstance(endpoint, dict): + continue + endpoint_id = str(endpoint.get('id') or '').strip() + if endpoint_id: + try: + ensure_governance_access( + 'governance_global_endpoints', + user_id, + item_entity_type='global_endpoint', + item_id=endpoint_id, + ) + except PermissionError: + continue + endpoints.append({**endpoint, '_endpoint_scope': 'global'}) + except PermissionError: + debug_print('[Streaming][Model Resolution] Global endpoint governance policy denied access to global endpoints.') return endpoints diff --git a/application/single_app/route_backend_governance.py b/application/single_app/route_backend_governance.py new file mode 100644 index 000000000..c72b42bbe --- /dev/null +++ b/application/single_app/route_backend_governance.py @@ -0,0 +1,221 @@ +# route_backend_governance.py + +"""Admin API routes for governance policy management.""" + +from flask import jsonify, request, session + +from functions_authentication import admin_required, get_current_user_id, login_required +from functions_governance import ( + DEFAULT_FEATURE_POLICIES, + bootstrap_default_feature_policies, + delete_item_policy, + list_feature_policies, + list_item_policies, + upsert_feature_policy, + upsert_item_policy, +) +from swagger_wrapper import get_auth_security, swagger_route + + +DEFAULT_GOVERNANCE_REVIEW_PAGE_SIZE = 25 +MAX_GOVERNANCE_REVIEW_PAGE_SIZE = 100 + + +def _normalize_actor_email() -> str: + user = session.get("user") if isinstance(session.get("user"), dict) else {} + return str(user.get("email") or "").strip() + + +def _sanitize_policy_payload(payload): + if not isinstance(payload, dict): + return { + "allow_all": True, + "allowed_users": [], + "allowed_groups": [], + "policy_id": "", + "policy_name": "", + "resource_label": "", + } + + allowed_users = payload.get("allowed_users", []) + if not isinstance(allowed_users, list): + allowed_users = [] + + allowed_groups = payload.get("allowed_groups", []) + if not isinstance(allowed_groups, list): + allowed_groups = [] + + return { + "allow_all": bool(payload.get("allow_all", True)), + "allowed_users": allowed_users, + "allowed_groups": allowed_groups, + "policy_id": str(payload.get("policy_id") or "").strip(), + "policy_name": str(payload.get("policy_name") or "").strip(), + "resource_label": str(payload.get("resource_label") or "").strip(), + } + + +def _normalize_review_pagination(args): + try: + page = int(str(args.get("page") or "1").strip()) + except (TypeError, ValueError): + page = 1 + + try: + per_page = int(str(args.get("per_page") or str(DEFAULT_GOVERNANCE_REVIEW_PAGE_SIZE)).strip()) + except (TypeError, ValueError): + per_page = DEFAULT_GOVERNANCE_REVIEW_PAGE_SIZE + + page = max(page, 1) + per_page = max(1, min(per_page, MAX_GOVERNANCE_REVIEW_PAGE_SIZE)) + return page, per_page + + +def _build_item_policy_search_haystack(policy): + return " ".join([ + str(policy.get("policy_id") or ""), + str(policy.get("policy_name") or ""), + str(policy.get("resource_label") or ""), + str(policy.get("entity_type") or ""), + str(policy.get("item_id") or ""), + str(policy.get("allow_all") or ""), + " ".join(str(value or "") for value in policy.get("allowed_users", []) if value), + " ".join(str(value or "") for value in policy.get("allowed_groups", []) if value), + ]).lower() + + +def register_route_backend_governance(app): + @app.route('/api/admin/governance/policies', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_governance_policies_route(): + bootstrap_default_feature_policies() + return jsonify({ + 'features': list_feature_policies(), + 'feature_keys': list(DEFAULT_FEATURE_POLICIES.keys()), + }), 200 + + @app.route('/api/admin/governance/policies/', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def update_governance_feature_policy_route(feature_key): + if feature_key not in DEFAULT_FEATURE_POLICIES: + return jsonify({'error': f"Unknown feature policy: {feature_key}"}), 400 + + payload = _sanitize_policy_payload(request.get_json(silent=True) or {}) + actor_user_id = str(get_current_user_id() or '').strip() + actor_email = _normalize_actor_email() + + updated = upsert_feature_policy( + feature_key=feature_key, + payload=payload, + actor_user_id=actor_user_id, + actor_email=actor_email, + ) + return jsonify({'policy': updated}), 200 + + def _delete_governance_item_policy(entity_type, item_id, policy_id=None): + normalized_entity_type = str(entity_type or '').strip().lower() + normalized_item_id = str(item_id or '').strip() + normalized_policy_id = str(policy_id or '').strip() or None + if not normalized_entity_type or not normalized_item_id: + return jsonify({'error': 'entity_type and item_id are required.'}), 400 + + actor_user_id = str(get_current_user_id() or '').strip() + actor_email = _normalize_actor_email() + + try: + deleted = delete_item_policy( + entity_type=normalized_entity_type, + item_id=normalized_item_id, + policy_id=normalized_policy_id, + actor_user_id=actor_user_id, + actor_email=actor_email, + ) + except Exception: + return jsonify({'error': 'Item governance policy not found.'}), 404 + + return jsonify({'deleted': deleted}), 200 + + @app.route('/api/admin/governance/item-policies//', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def delete_governance_item_policy_route(entity_type, item_id): + return _delete_governance_item_policy(entity_type, item_id) + + @app.route('/api/admin/governance/item-policies///', methods=['DELETE']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def delete_governance_named_item_policy_route(entity_type, item_id, policy_id): + return _delete_governance_item_policy(entity_type, item_id, policy_id) + + @app.route('/api/admin/governance/item-policies', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def get_governance_item_policies_route(): + entity_type = str(request.args.get('entity_type') or '').strip() or None + return jsonify({'item_policies': list_item_policies(entity_type=entity_type)}), 200 + + @app.route('/api/admin/governance/item-policies/review', methods=['GET']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def review_governance_item_policies_route(): + entity_type = str(request.args.get('entity_type') or '').strip().lower() or None + search = str(request.args.get('search') or '').strip().lower() + page, per_page = _normalize_review_pagination(request.args) + + policies = list_item_policies(entity_type=entity_type) + if search: + policies = [ + policy for policy in policies + if search in _build_item_policy_search_haystack(policy) + ] + + total_items = len(policies) + total_pages = (total_items + per_page - 1) // per_page if total_items else 1 + page = min(page, total_pages) + offset = (page - 1) * per_page + paged_policies = policies[offset:offset + per_page] + + return jsonify({ + 'item_policies': paged_policies, + 'pagination': { + 'page': page, + 'per_page': per_page, + 'total_items': total_items, + 'total_pages': total_pages, + 'has_prev': page > 1, + 'has_next': page < total_pages, + }, + 'search': search, + 'entity_type': entity_type, + }), 200 + + @app.route('/api/admin/governance/item-policies//', methods=['PUT']) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def update_governance_item_policy_route(entity_type, item_id): + normalized_entity_type = str(entity_type or '').strip().lower() + normalized_item_id = str(item_id or '').strip() + if not normalized_entity_type or not normalized_item_id: + return jsonify({'error': 'entity_type and item_id are required.'}), 400 + + payload = _sanitize_policy_payload(request.get_json(silent=True) or {}) + actor_user_id = str(get_current_user_id() or '').strip() + actor_email = _normalize_actor_email() + + updated = upsert_item_policy( + entity_type=normalized_entity_type, + item_id=normalized_item_id, + payload=payload, + actor_user_id=actor_user_id, + actor_email=actor_email, + ) + return jsonify({'policy': updated}), 200 diff --git a/application/single_app/route_backend_groups.py b/application/single_app/route_backend_groups.py index 9186e4ce4..705e301ef 100644 --- a/application/single_app/route_backend_groups.py +++ b/application/single_app/route_backend_groups.py @@ -41,9 +41,10 @@ def discover_groups(): for g in all_items: name = g.get("name", "").lower() desc = g.get("description", "").lower() + group_id = str(g.get("id", "")).lower() if search_query: - if search_query not in name and search_query not in desc: + if search_query not in name and search_query not in desc and search_query not in group_id: continue if not show_all: diff --git a/application/single_app/route_backend_models.py b/application/single_app/route_backend_models.py index c047b5b00..9ba95c0d9 100644 --- a/application/single_app/route_backend_models.py +++ b/application/single_app/route_backend_models.py @@ -4,6 +4,7 @@ from config import * from functions_authentication import * +from functions_governance import ensure_governance_access from functions_group import assert_group_role, get_group_model_endpoints, require_active_group, update_group_model_endpoints from functions_keyvault import SecretReturnType, keyvault_model_endpoint_cleanup_helper, keyvault_model_endpoint_delete_helper, keyvault_model_endpoint_get_helper, keyvault_model_endpoint_save_helper from functions_settings import * @@ -81,20 +82,60 @@ def build_group_access_error_response(user_id, exception, resource_name): def resolve_scoped_model_endpoints(user_id, scope): settings = get_settings() endpoints = [] + + def get_governed_endpoints(candidate_endpoints, feature_key, endpoint_scope): + try: + ensure_governance_access(feature_key, user_id) + except PermissionError: + return [] + + governed_endpoints = [] + for endpoint in candidate_endpoints or []: + if not isinstance(endpoint, dict): + continue + endpoint_id = str(endpoint.get("id") or "").strip() + if endpoint_id: + try: + ensure_governance_access( + feature_key, + user_id, + item_entity_type="global_endpoint", + item_id=endpoint_id, + ) + except PermissionError: + continue + governed_endpoint = dict(endpoint) + governed_endpoint["_governance_endpoint_scope"] = endpoint_scope + governed_endpoints.append(governed_endpoint) + return governed_endpoints + if scope == "group": if settings.get("allow_group_custom_endpoints", False): group_id = require_active_group(user_id) - endpoints.extend(get_group_model_endpoints(group_id)) + endpoints.extend(get_governed_endpoints(get_group_model_endpoints(group_id), "governance_group_endpoints", "group")) elif scope == "user": if settings.get("allow_user_custom_endpoints", False): user_settings = get_user_settings(user_id) - endpoints.extend(user_settings.get("settings", {}).get("personal_model_endpoints", [])) - endpoints.extend(settings.get("model_endpoints", []) or []) + endpoints.extend(get_governed_endpoints(user_settings.get("settings", {}).get("personal_model_endpoints", []), "governance_user_endpoints", "user")) + endpoints.extend(get_governed_endpoints(settings.get("model_endpoints", []) or [], "governance_global_endpoints", "global")) return endpoints def resolve_endpoint_by_id(user_id, scope, endpoint_id): endpoints = resolve_scoped_model_endpoints(user_id, scope) - return next((endpoint for endpoint in endpoints if endpoint.get("id") == endpoint_id), None) + endpoint = next((endpoint for endpoint in endpoints if endpoint.get("id") == endpoint_id), None) + if endpoint: + endpoint = dict(endpoint) + endpoint_scope = endpoint.pop("_governance_endpoint_scope", scope) + feature_key = "governance_global_endpoints" + if endpoint_scope in ("user", "group"): + feature_key = f"governance_{endpoint_scope}_endpoints" + ensure_governance_access( + feature_key, + user_id, + item_entity_type="global_endpoint", + item_id=endpoint_id, + ) + return endpoint def resolve_endpoint_scope_value(endpoint_cfg, fallback_endpoint_id=""): endpoint_id = (fallback_endpoint_id or endpoint_cfg.get("id") or "").strip() @@ -709,6 +750,9 @@ def test_model_inference_connection(): level=logging.WARNING, ) return build_safe_error_response("The selected model endpoint could not be found.", 404) + except PermissionError as e: + log_models_exception("Test connection blocked by governance policy", e, level=logging.WARNING) + return build_safe_error_response(str(e), 403) except ValueError as e: log_models_exception("Test connection validation failed", e, level=logging.WARNING) return build_safe_error_response( @@ -739,6 +783,10 @@ def fetch_model_list(): @enabled_required('allow_user_custom_endpoints') def get_user_model_endpoints(): user_id = get_current_user_id() + try: + ensure_governance_access("governance_user_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 user_settings = get_user_settings(user_id) endpoints = user_settings.get("settings", {}).get("personal_model_endpoints", []) return jsonify({ @@ -753,6 +801,10 @@ def get_user_model_endpoints(): @enabled_required('allow_user_custom_endpoints') def save_user_model_endpoints(): user_id = get_current_user_id() + try: + ensure_governance_access("governance_user_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 data = request.get_json() or {} incoming = data.get("endpoints", []) if not isinstance(incoming, list): @@ -817,6 +869,7 @@ def save_user_model_endpoints(): def get_group_model_endpoints_route(): user_id = get_current_user_id() try: + ensure_governance_access("governance_group_endpoints", user_id) group_id = require_active_group(user_id) assert_group_role( user_id, @@ -843,6 +896,10 @@ def get_group_model_endpoints_route(): @enabled_required('allow_group_custom_endpoints') def save_group_model_endpoints(): user_id = get_current_user_id() + try: + ensure_governance_access("governance_group_endpoints", user_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 data = request.get_json() or {} incoming = data.get("endpoints", []) if not isinstance(incoming, list): @@ -932,7 +989,10 @@ def list_foundry_agents(): except PermissionError as exc: return build_group_access_error_response(user_id, exc, "group Foundry agents") - endpoint_cfg = resolve_endpoint_by_id(user_id, scope, endpoint_id) + try: + endpoint_cfg = resolve_endpoint_by_id(user_id, scope, endpoint_id) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 if not endpoint_cfg: return jsonify({"error": "Model endpoint not found."}), 404 endpoint_cfg = keyvault_model_endpoint_get_helper( diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index b60f81894..4db01ae21 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -3,7 +3,7 @@ import re import builtins import json -from flask import Blueprint, jsonify, request, current_app +from flask import Blueprint, jsonify, request, current_app, session from semantic_kernel_plugins.plugin_loader import get_all_plugin_metadata from semantic_kernel_plugins.plugin_health_checker import PluginHealthChecker, PluginErrorRecovery from functions_settings import get_settings, is_tabular_processing_enabled, update_settings @@ -44,6 +44,7 @@ log_action_update, log_action_deletion, ) +from functions_governance import ensure_governance_access, upsert_item_policy def discover_plugin_types(): # Dynamically discover allowed plugin types from available plugin classes. @@ -308,8 +309,21 @@ def get_user_plugins(): if merge_global: # Import and get global actions from container global_plugins = get_global_actions() - # Mark global plugins + filtered_global_plugins = [] for plugin in global_plugins: + try: + ensure_governance_access( + 'governance_global_actions_usage', + user_id, + item_entity_type='global_action', + item_id=str(plugin.get('id') or plugin.get('name') or ''), + ) + except PermissionError: + continue + filtered_global_plugins.append(plugin) + + # Mark global plugins + for plugin in filtered_global_plugins: plugin['is_global'] = True # Merge plugins using ID as key to avoid name conflicts @@ -322,7 +336,7 @@ def get_user_plugins(): all_plugins[key] = plugin # Add global plugins - for plugin in global_plugins: + for plugin in filtered_global_plugins: key = f"global_{plugin.get('id', plugin['name'])}" all_plugins[key] = plugin @@ -336,6 +350,11 @@ def get_user_plugins(): @enabled_required("allow_user_plugins") def set_user_plugins(): user_id = get_current_user_id() + try: + ensure_governance_access('governance_user_actions', user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 + plugins = request.json if isinstance(request.json, list) else [] # Get global plugin names (case-insensitive) @@ -581,6 +600,8 @@ def create_group_action_route(): try: saved = save_group_action(active_group, payload, user_id=user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as exc: debug_print('Failed to save group action: %s', exc) return jsonify({'error': 'Unable to save action'}), 500 @@ -650,6 +671,8 @@ def update_group_action_route(action_id): try: saved = save_group_action(active_group, merged, user_id=user_id) + except PermissionError as exc: + return jsonify({'error': str(exc)}), 403 except Exception as exc: debug_print('Failed to update group action %s: %s', action_id, exc) return jsonify({'error': 'Unable to update action'}), 500 @@ -819,9 +842,19 @@ def add_plugin(): # Assign a unique ID plugin_id = str(uuid.uuid4()) new_plugin['id'] = plugin_id + governance_policy_payload = new_plugin.pop('governance_policy', None) # Save to global actions container save_global_action(new_plugin, user_id=str(get_current_user_id())) + + if isinstance(governance_policy_payload, dict): + upsert_item_policy( + entity_type='global_action', + item_id=plugin_id, + payload=governance_policy_payload, + actor_user_id=str(get_current_user_id() or ''), + actor_email=str((session.get('user') or {}).get('email') or ''), + ) log_action_creation(user_id=str(get_current_user_id()), action_id=plugin_id, action_name=new_plugin.get('name', ''), action_type=new_plugin.get('type', ''), scope='global') log_event("Plugin added", extra={"action": "add", "plugin": _redact_plugin_for_logging(new_plugin), "user": str(get_current_user_id())}) @@ -841,6 +874,7 @@ def edit_plugin(plugin_name): try: plugins = get_global_actions() updated_plugin = request.json + governance_policy_payload = updated_plugin.pop('governance_policy', None) if isinstance(updated_plugin, dict) else None # Strict validation with dynamic allowed types allowed_types = discover_plugin_types() @@ -889,6 +923,15 @@ def edit_plugin(plugin_name): updated_plugin['id'] = str(uuid.uuid4()) save_global_action(updated_plugin, user_id=str(get_current_user_id())) + + if isinstance(governance_policy_payload, dict): + upsert_item_policy( + entity_type='global_action', + item_id=str(updated_plugin.get('id') or ''), + payload=governance_policy_payload, + actor_user_id=str(get_current_user_id() or ''), + actor_email=str((session.get('user') or {}).get('email') or ''), + ) log_action_update(user_id=str(get_current_user_id()), action_id=updated_plugin.get('id', ''), action_name=plugin_name, action_type=updated_plugin.get('type', ''), scope='global') log_event("Plugin edited", extra={"action": "edit", "plugin": _redact_plugin_for_logging(updated_plugin), "user": str(get_current_user_id())}) diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index eac97c37e..a7273e864 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -1,6 +1,9 @@ # route_backend_users.py +from urllib.parse import quote + from config import * +from functions_appinsights import log_event from functions_authentication import * from functions_group import update_active_group_for_user from functions_public_workspaces import update_active_public_workspace_for_user @@ -12,6 +15,49 @@ def _escape_graph_odata_literal(value): return str(value or "").replace("'", "''") +def _build_user_info_response(user_id, display_name="", email="", user_principal_name=""): + resolved_email = email or user_principal_name or "" + return { + "id": user_id, + "user_id": user_id, + "displayName": display_name or resolved_email or "", + "display_name": display_name or resolved_email or "", + "email": resolved_email, + "mail": email or "", + "userPrincipalName": user_principal_name or resolved_email, + } + + +def _get_graph_user_info_by_id(user_id): + normalized_user_id = str(user_id or "").strip() + if not normalized_user_id: + return None + + token = get_valid_access_token() + if not token: + return None + + user_endpoint = get_graph_endpoint(f"/users/{quote(normalized_user_id, safe='')}") + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + params = { + "$select": "id,displayName,mail,userPrincipalName" + } + + response = requests.get(user_endpoint, headers=headers, params=params) + response.raise_for_status() + user = response.json() or {} + graph_user_id = user.get("id") or normalized_user_id + return _build_user_info_response( + graph_user_id, + display_name=user.get("displayName", ""), + email=user.get("mail", ""), + user_principal_name=user.get("userPrincipalName", ""), + ) + + def register_route_backend_users(app): """ This route will expose GET /api/userSearch?query= which calls @@ -88,24 +134,41 @@ def api_get_user_info(user_id): """ Get user info (email, display_name) by user_id (oid). """ - # Directly query Cosmos for the user document by id (oid) - from config import cosmos_user_settings_container + normalized_user_id = str(user_id or "").strip() + if not normalized_user_id: + return jsonify({"error": "User ID is required"}), 400 + try: user_doc = cosmos_user_settings_container.read_item( - item=user_id, - partition_key=user_id + item=normalized_user_id, + partition_key=normalized_user_id ) - print(f"/api/user/info/{user_id} → doc: {user_doc}", flush=True) - return jsonify({ - "user_id": user_id, - "email": user_doc.get("email", ""), - "display_name": user_doc.get("display_name", "") - }), 200 - except Exception as e: - print(f"[ERROR] /api/user/info/{user_id} failed: {e}", flush=True) - return jsonify({ - "error": f"User not found for oid {user_id}" - }), 404 + return jsonify(_build_user_info_response( + normalized_user_id, + display_name=user_doc.get("display_name", ""), + email=user_doc.get("email", ""), + )), 200 + except Exception: + pass + + try: + graph_user_info = _get_graph_user_info_by_id(normalized_user_id) + if graph_user_info: + return jsonify(graph_user_info), 200 + except requests.exceptions.RequestException as ex: + log_event( + "[Users] Graph user info lookup failed", + level=logging.WARNING, + extra={ + "target_user_id": normalized_user_id, + "status_code": getattr(ex.response, "status_code", None), + }, + debug_only=True, + ) + + return jsonify({ + "error": f"User not found for oid {normalized_user_id}" + }), 404 @app.route('/api/user/settings', methods=['GET', 'POST']) @swagger_route(security=get_auth_security()) diff --git a/application/single_app/route_custom_pages.py b/application/single_app/route_custom_pages.py new file mode 100644 index 000000000..d65165d5d --- /dev/null +++ b/application/single_app/route_custom_pages.py @@ -0,0 +1,273 @@ +# route_custom_pages.py +"""Routes for trusted deployment-time custom pages.""" + +import mimetypes +import os +import logging + +from flask import Response, jsonify, render_template, request, send_file, session + +from functions_appinsights import log_event +from functions_authentication import admin_required, login_required +from functions_custom_pages import ( + build_custom_page_context, + delete_custom_page, + get_custom_page, + is_custom_page_authorized, + is_custom_pages_enabled, + list_custom_pages, + resolve_custom_page_file, + save_custom_page, + validate_custom_page_metadata, +) +from functions_settings import get_settings, sanitize_settings_for_user, update_settings +from swagger_wrapper import get_auth_security, swagger_route + + +def _custom_pages_disabled_response(): + return "Not Found", 404 + + +def _load_enabled_custom_page_or_404(slug): + settings = get_settings() + if not is_custom_pages_enabled(settings): + return None, settings, _custom_pages_disabled_response() + + page = get_custom_page(slug, include_python=True) + if not page or not page.get("enabled", True): + return None, settings, ("Not Found", 404) + + if not is_custom_page_authorized(page): + return None, settings, ("Forbidden", 403) + + return page, settings, None + + +def _current_admin_user_id(): + user = session.get("user", {}) if session else {} + if not isinstance(user, dict): + return "admin" + return str(user.get("oid") or user.get("preferred_username") or user.get("email") or "admin") + + +def _get_custom_pages_developer_guide_path(): + """Return the path for the canonical in-app Custom Pages developer guide.""" + app_root = os.path.dirname(__file__) + guide_path = os.path.abspath(os.path.join(app_root, "docs", "how-to", "custom_pages.md")) + return guide_path if os.path.isfile(guide_path) else None + + +def _strip_markdown_front_matter(markdown_text): + """Remove Jekyll front matter before rendering markdown inside the app.""" + if not markdown_text.startswith("---"): + return markdown_text + parts = markdown_text.split("---", 2) + if len(parts) == 3: + return parts[2].lstrip() + return markdown_text + + +def _render_custom_page_response(slug): + """Render a custom page response for canonical and compatibility routes.""" + page, settings, error_response = _load_enabled_custom_page_or_404(slug) + if error_response: + return error_response + + if page.get("entry_type") == "python": + extension = page.get("extension") + if not extension or not hasattr(extension, "render"): + return "Not Found", 404 + return extension.render(build_custom_page_context(page, sanitize_settings_for_user(settings))) + + html_file = page.get("html_file") + html_path, error = resolve_custom_page_file(page, "html", html_file) + if error: + log_event( + "[CustomPages] Unable to resolve custom page HTML.", + extra={"slug": slug, "error": error}, + level=logging.WARNING, + ) + return "Not Found", 404 + + with open(html_path, "r", encoding="utf-8") as html_handle: + trusted_html = html_handle.read() + + return render_template( + "custom_page_shell.html", + custom_page=page, + custom_page_html=trusted_html, + settings=sanitize_settings_for_user(settings), + ) + + +def register_route_custom_pages(app): + @app.route("/custom/", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + def custom_page(slug): + """Render a trusted custom page.""" + return _render_custom_page_response(slug) + + @app.route("/custom/.html", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + def custom_page_html_alias(slug): + """Render a trusted custom page from a familiar .html URL alias.""" + return _render_custom_page_response(slug) + + @app.route("/custom/assets///", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + def custom_page_asset(slug, folder, filename): + """Serve a declared custom page asset.""" + page, _, error_response = _load_enabled_custom_page_or_404(slug) + if error_response: + return error_response + + file_path, error = resolve_custom_page_file(page, folder, filename) + if error: + log_event( + "[CustomPages] Blocked custom page asset request.", + extra={"slug": slug, "folder": folder, "filename": filename, "error": error}, + level=logging.WARNING, + ) + return "Not Found", 404 + + mimetype = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + response = send_file(file_path, mimetype=mimetype, conditional=True) + if os.path.splitext(file_path)[1].lower() == ".mjs": + response.headers["Content-Type"] = "application/javascript" + return response + + @app.route("/api/custom//", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) + @swagger_route(security=get_auth_security()) + @login_required + def custom_page_api(slug, operation): + """Dispatch a trusted Python-backed custom page API operation.""" + page, settings, error_response = _load_enabled_custom_page_or_404(slug) + if error_response: + return error_response + if page.get("entry_type") != "python" or not page.get("extension"): + return jsonify({"error": "Custom page API not found"}), 404 + try: + return page["extension"].handle_api( + operation, + build_custom_page_context(page, sanitize_settings_for_user(settings)), + ) + except NotImplementedError: + return jsonify({"error": "Custom page API not found"}), 404 + + @app.route("/api/admin/custom-pages", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def admin_list_custom_pages(): + """List custom page metadata for the Admin Settings designer.""" + settings = get_settings() + include_python = is_custom_pages_enabled(settings) + pages = list_custom_pages(include_python=include_python) + safe_pages = [{key: value for key, value in page.items() if key != "extension"} for page in pages] + return jsonify({"pages": safe_pages}) + + @app.route("/api/admin/custom-pages/developer-guide", methods=["GET"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def admin_custom_pages_developer_guide(): + """Return the canonical Custom Pages developer guide markdown for the in-app modal.""" + guide_path = _get_custom_pages_developer_guide_path() + if not guide_path: + return jsonify({"error": "Custom Pages developer guide was not found."}), 404 + + try: + with open(guide_path, "r", encoding="utf-8") as guide_handle: + markdown_text = _strip_markdown_front_matter(guide_handle.read()) + return jsonify({"markdown": markdown_text}) + except Exception as ex: + log_event( + "[CustomPages] Failed to load developer guide.", + extra={"error": str(ex)}, + level=logging.ERROR, + exceptionTraceback=True, + ) + return jsonify({"error": "Custom Pages developer guide could not be loaded."}), 500 + + @app.route("/api/admin/custom-pages", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def admin_create_custom_page(): + """Create static custom page metadata.""" + payload = request.get_json(silent=True) or {} + errors = validate_custom_page_metadata(payload, require_static_html=True) + if errors: + return jsonify({"error": "; ".join(errors)}), 400 + slug = str(payload.get("slug") or payload.get("id") or "").strip().lower() + if get_custom_page(slug, include_python=True): + return jsonify({"error": "A custom page with this slug already exists."}), 409 + saved = save_custom_page(payload, user_id=_current_admin_user_id()) + return jsonify(saved), 201 + + @app.route("/api/admin/custom-pages/request-access-example", methods=["POST"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def admin_create_request_access_custom_page(): + """Create or update the optional Request Access custom page metadata.""" + request_access_page = { + "slug": "request-access", + "title": "Request SimpleChat Access", + "description": "A signed-in access request page for users who need the base User role.", + "entry_type": "static", + "access_level": "authenticated", + "nav_label": "Request Access", + "nav_icon": "bi-person-plus", + "nav_order": 5, + "roles": [], + "show_in_nav": False, + "open_in_new_tab": False, + "html_file": "request-access.html", + "css_files": ["request-access.css"], + "js_files": [], + "asset_files": [], + "json_files": [], + "enabled": True, + } + errors = validate_custom_page_metadata(request_access_page, require_static_html=True) + if errors: + return jsonify({"error": "; ".join(errors)}), 400 + + saved = save_custom_page(request_access_page, user_id=_current_admin_user_id()) + update_settings({ + "access_request_button_enabled": True, + "access_request_button_text": "Request Access", + "access_request_page_url": "/custom/request-access", + }) + return jsonify({"page": saved, "access_request_button_enabled": True}), 201 + + @app.route("/api/admin/custom-pages/", methods=["PUT"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def admin_update_custom_page(slug): + """Update static custom page metadata.""" + payload = request.get_json(silent=True) or {} + payload["slug"] = slug + errors = validate_custom_page_metadata(payload, require_static_html=True) + if errors: + return jsonify({"error": "; ".join(errors)}), 400 + saved = save_custom_page(payload, user_id=_current_admin_user_id()) + return jsonify(saved), 200 + + @app.route("/api/admin/custom-pages/", methods=["DELETE"]) + @swagger_route(security=get_auth_security()) + @login_required + @admin_required + def admin_delete_custom_page(slug): + """Delete static custom page metadata.""" + page = get_custom_page(slug, include_python=True) + if page and page.get("source") == "python": + return jsonify({"error": "Python-backed custom pages are managed in code."}), 400 + if not delete_custom_page(slug): + return jsonify({"error": "Custom page not found."}), 404 + return Response(status=204) \ No newline at end of file diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 435134c65..b4249d3a1 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -5,7 +5,7 @@ from functions_authentication import * from functions_keyvault import keyvault_model_endpoint_cleanup_helper, keyvault_model_endpoint_delete_helper, keyvault_model_endpoint_save_helper, redact_model_endpoint_secret_values from functions_settings import * -from functions_activity_logging import log_web_search_consent_acceptance, log_general_admin_action +from functions_activity_logging import log_web_search_consent_acceptance, log_general_admin_action, log_governance_change from functions_notifications import broadcast_system_notification from functions_logging import * from swagger_wrapper import swagger_route, get_auth_security @@ -64,6 +64,12 @@ def admin_settings(): 'model_id': '', 'provider': '' } + if 'metadata_extraction_model_selection' not in settings or not isinstance(settings.get('metadata_extraction_model_selection'), dict): + settings['metadata_extraction_model_selection'] = { + 'endpoint_id': '', + 'model_id': '', + 'provider': '' + } if 'multi_endpoint_migrated_at' not in settings: settings['multi_endpoint_migrated_at'] = None if 'multi_endpoint_migration_notice' not in settings or not isinstance(settings.get('multi_endpoint_migration_notice'), dict): @@ -115,6 +121,18 @@ def admin_settings(): {"label": "Acceptable Use Policy", "url": "https://example.com/policy"}, {"label": "Prompt Ideas", "url": "https://example.com/prompts"} ] + if 'enable_custom_pages' not in settings: + settings['enable_custom_pages'] = False + if 'custom_pages_menu_name' not in settings: + settings['custom_pages_menu_name'] = 'Custom Pages' + if 'custom_pages_force_menu' not in settings: + settings['custom_pages_force_menu'] = False + if 'access_request_button_enabled' not in settings: + settings['access_request_button_enabled'] = False + if 'access_request_button_text' not in settings: + settings['access_request_button_text'] = 'Request Access' + if 'access_request_page_url' not in settings: + settings['access_request_page_url'] = '/custom/request-access' if 'enable_support_menu' not in settings: settings['enable_support_menu'] = False if 'support_menu_name' not in settings: @@ -563,6 +581,20 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul # Keep existing external links from the database instead of overwriting with bad data parsed_external_links = settings.get('external_links', []) # Fallback to existing + enable_custom_pages = form_data.get('enable_custom_pages') == 'on' + custom_pages_was_enabled = bool(settings.get('enable_custom_pages', False)) + custom_pages_restart_acknowledged = form_data.get('custom_pages_restart_acknowledged') == 'on' + if enable_custom_pages and not custom_pages_was_enabled and not custom_pages_restart_acknowledged: + flash( + 'Custom Pages requires acknowledgement that the App Service must be restarted before it is fully enabled. The feature was not enabled.', + 'danger' + ) + enable_custom_pages = False + custom_pages_menu_name = form_data.get('custom_pages_menu_name', 'Custom Pages').strip() + if not custom_pages_menu_name: + custom_pages_menu_name = 'Custom Pages' + custom_pages_force_menu = form_data.get('custom_pages_force_menu') == 'on' + enable_support_menu = form_data.get('enable_support_menu') == 'on' support_menu_name = form_data.get('support_menu_name', 'Support').strip() if not support_menu_name: @@ -829,6 +861,75 @@ def parse_admin_int(raw_value, fallback_value, field_name="unknown", hard_defaul 'provider': '' } + metadata_selection_json = form_data.get('metadata_extraction_model_selection_json', '{}') + parsed_metadata_model_selection = {} + try: + parsed_metadata_model_selection_raw = ( + json.loads(metadata_selection_json) if metadata_selection_json else {} + ) + if isinstance(parsed_metadata_model_selection_raw, dict): + parsed_metadata_model_selection = parsed_metadata_model_selection_raw + else: + raise ValueError("Invalid format: metadata_extraction_model_selection must be an object.") + except (json.JSONDecodeError, ValueError) as e: + print(f"Error processing metadata_extraction_model_selection_json: {e}") + flash(f"Error processing metadata extraction model selection: {e}. Changes not saved.", 'danger') + parsed_metadata_model_selection = settings.get('metadata_extraction_model_selection', {}) + + normalized_metadata_model_selection = { + 'endpoint_id': str(parsed_metadata_model_selection.get('endpoint_id') or '').strip(), + 'model_id': str(parsed_metadata_model_selection.get('model_id') or '').strip(), + 'provider': str(parsed_metadata_model_selection.get('provider') or '').strip().lower() + } + metadata_extraction_model_deployment = form_data.get('metadata_extraction_model', '').strip() + + if not enable_multi_model_endpoints: + normalized_metadata_model_selection = { + 'endpoint_id': '', + 'model_id': '', + 'provider': '' + } + elif normalized_metadata_model_selection['endpoint_id'] and normalized_metadata_model_selection['model_id']: + endpoint_cfg = next( + (e for e in parsed_model_endpoints if e.get('id') == normalized_metadata_model_selection['endpoint_id']), + None + ) + if not endpoint_cfg or not endpoint_cfg.get('enabled', True): + flash('Metadata extraction model endpoint is not available. Please select a valid endpoint.', 'warning') + normalized_metadata_model_selection = { + 'endpoint_id': '', + 'model_id': '', + 'provider': '' + } + else: + models = endpoint_cfg.get('models', []) or [] + model_cfg = next( + (m for m in models if m.get('id') == normalized_metadata_model_selection['model_id']), + None + ) + if not model_cfg or not model_cfg.get('enabled', True): + flash('Metadata extraction model is not available. Please select a valid model.', 'warning') + normalized_metadata_model_selection = { + 'endpoint_id': '', + 'model_id': '', + 'provider': '' + } + else: + endpoint_provider = (endpoint_cfg.get('provider') or '').strip().lower() + if endpoint_provider: + normalized_metadata_model_selection['provider'] = endpoint_provider + metadata_extraction_model_deployment = str( + model_cfg.get('deploymentName') + or model_cfg.get('deployment') + or '' + ).strip() + else: + normalized_metadata_model_selection = { + 'endpoint_id': '', + 'model_id': '', + 'provider': '' + } + # --- Extract banner fields from form_data --- classification_banner_enabled = form_data.get('classification_banner_enabled') == 'on' classification_banner_text = form_data.get('classification_banner_text', '').strip() @@ -1112,6 +1213,15 @@ def is_valid_url(url): 'enable_agent_template_gallery': form_data.get('enable_agent_template_gallery') == 'on', 'agent_templates_allow_user_submission': form_data.get('agent_templates_allow_user_submission') == 'on', 'agent_templates_require_approval': form_data.get('agent_templates_require_approval') == 'on', + 'governance_user_endpoints': form_data.get('governance_user_endpoints') == 'on' and form_data.get('allow_user_custom_endpoints') == 'on', + 'governance_group_endpoints': form_data.get('governance_group_endpoints') == 'on' and form_data.get('allow_group_custom_endpoints') == 'on', + 'governance_global_endpoints': True, + 'governance_user_agents': form_data.get('governance_user_agents') == 'on' and form_data.get('allow_user_agents') == 'on', + 'governance_group_agents': form_data.get('governance_group_agents') == 'on' and form_data.get('allow_group_agents') == 'on', + 'governance_global_agents_usage': form_data.get('governance_global_agents_usage') == 'on' and form_data.get('enable_semantic_kernel') == 'on', + 'governance_user_actions': form_data.get('governance_user_actions') == 'on' and form_data.get('allow_user_plugins') == 'on', + 'governance_group_actions': form_data.get('governance_group_actions') == 'on' and form_data.get('allow_group_plugins') == 'on', + 'governance_global_actions_usage': form_data.get('governance_global_actions_usage') == 'on' and form_data.get('enable_semantic_kernel') == 'on', # GPT (Direct & APIM) 'enable_gpt_apim': form_data.get('enable_gpt_apim') == 'on', @@ -1221,6 +1331,14 @@ def is_valid_url(url): 'external_links_force_menu': external_links_force_menu, 'external_links': parsed_external_links, # Store the PARSED LIST + # *** Custom Pages *** + 'enable_custom_pages': enable_custom_pages, + 'custom_pages_menu_name': custom_pages_menu_name, + 'custom_pages_force_menu': custom_pages_force_menu, + 'access_request_button_enabled': bool(settings.get('access_request_button_enabled', False)), + 'access_request_button_text': settings.get('access_request_button_text', 'Request Access'), + 'access_request_page_url': settings.get('access_request_page_url', '/custom/request-access'), + # *** Support Menu *** 'enable_support_menu': enable_support_menu, 'support_menu_name': support_menu_name, @@ -1353,7 +1471,8 @@ def is_valid_url(url): # Text-to-speech chat output 'enable_text_to_speech': form_data.get('enable_text_to_speech') == 'on', - 'metadata_extraction_model': form_data.get('metadata_extraction_model', '').strip(), + 'metadata_extraction_model': metadata_extraction_model_deployment, + 'metadata_extraction_model_selection': normalized_metadata_model_selection, # Multi-modal vision settings 'enable_multimodal_vision': form_data.get('enable_multimodal_vision') == 'on', @@ -1621,10 +1740,46 @@ def is_valid_url(url): flash(f"Error processing favicon file: {e}. Existing favicon preserved.", "danger") log_event(f"Error processing favicon file: {e}", level=logging.ERROR) + governance_toggle_keys = [ + 'governance_user_endpoints', + 'governance_group_endpoints', + 'governance_global_endpoints', + 'governance_user_agents', + 'governance_group_agents', + 'governance_global_agents_usage', + 'governance_user_actions', + 'governance_group_actions', + 'governance_global_actions_usage', + ] + governance_toggle_changes = {} + for toggle_key in governance_toggle_keys: + before_value = bool(settings.get(toggle_key, False)) + after_value = bool(new_settings.get(toggle_key, False)) + if before_value != after_value: + governance_toggle_changes[toggle_key] = { + 'before': before_value, + 'after': after_value, + } + # --- Update settings in DB --- # new_settings now contains either the new logo/favicon base64 or the original ones if update_settings(new_settings): flash("Admin settings updated successfully.", "success") + if enable_custom_pages and not custom_pages_was_enabled and custom_pages_restart_acknowledged: + log_general_admin_action( + admin_user_id=user_id, + admin_email=admin_email, + action='custom_pages_enabled_acknowledged', + description='Custom Pages enabled after restart acknowledgement.', + additional_context={ + 'feature': 'custom_pages', + 'enabled': True, + 'restart_required': True, + 'acknowledgement': 'Admin acknowledged that the App Service must be restarted before Custom Pages is fully enabled.', + 'custom_pages_menu_name': custom_pages_menu_name, + 'custom_pages_force_menu': custom_pages_force_menu, + } + ) # Reconfigure Application Insights logging immediately if the setting changed from functions_appinsights import setup_appinsights_logging setup_appinsights_logging(get_settings()) @@ -1638,6 +1793,32 @@ def is_valid_url(url): else: print("ERROR: Could not fetch settings after update to ensure logo/favicon files.") + if governance_toggle_changes: + try: + log_governance_change( + admin_user_id=user_id, + admin_email=admin_email, + action='governance_feature_toggles_updated', + scope='feature_policy', + target_id='governance_feature_toggles', + before_state={ + key: bool(settings.get(key, False)) + for key in governance_toggle_keys + }, + after_state={ + key: bool(new_settings.get(key, False)) + for key in governance_toggle_keys + }, + change_details={ + 'changed_toggles': governance_toggle_changes + }, + ) + except Exception as governance_log_error: + log_event( + f"Failed to log governance toggle change: {governance_log_error}", + level=logging.ERROR, + ) + if chunk_size_changed: try: log_general_admin_action( diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 2679f57b4..67eec3a52 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -9,6 +9,7 @@ from functions_group import find_group_by_id, get_group_model_endpoints, get_user_groups from functions_group_agents import get_group_agents from functions_global_agents import get_global_agents +from functions_governance import ensure_governance_access from functions_personal_agents import ensure_migration_complete, get_personal_agents from functions_prompts import list_all_prompts_for_scope from functions_public_workspaces import find_public_workspace_by_id, get_user_visible_public_workspace_ids_from_settings @@ -46,6 +47,49 @@ def _normalize_chat_model_value(value): return str(value or '').strip() +def _is_chat_agent_allowed_by_governance(user_id, agent, scope_type): + try: + if scope_type == 'global': + ensure_governance_access( + 'governance_global_agents_usage', + user_id, + item_entity_type='global_agent', + item_id=str(agent.get('id') or agent.get('name') or ''), + ) + elif scope_type == 'group': + ensure_governance_access('governance_group_agents', user_id) + else: + ensure_governance_access('governance_user_agents', user_id) + return True + except PermissionError: + return False + + +def _filter_chat_model_endpoints_by_governance(user_id, endpoints, feature_key): + try: + ensure_governance_access(feature_key, user_id) + except PermissionError: + return [] + + allowed_endpoints = [] + for endpoint in endpoints or []: + if not isinstance(endpoint, dict): + continue + endpoint_id = str(endpoint.get('id') or '').strip() + if endpoint_id: + try: + ensure_governance_access( + feature_key, + user_id, + item_entity_type='global_endpoint', + item_id=endpoint_id, + ) + except PermissionError: + continue + allowed_endpoints.append(endpoint) + return allowed_endpoints + + def _build_initial_chat_model_selection(*, chat_model_options, preferred_model_id=None, preferred_model_deployment=None): scope_order = { 'global': 0, @@ -165,11 +209,16 @@ def append_models(endpoints, scope_type, scope_id=None, scope_name=None): 'scope_name': scope_name, }) - append_models(settings.get('model_endpoints', []) or [], 'global', None, 'Global') + append_models( + _filter_chat_model_endpoints_by_governance(user_id, settings.get('model_endpoints', []) or [], 'governance_global_endpoints'), + 'global', + None, + 'Global' + ) if settings.get('allow_user_custom_endpoints', False): append_models( - user_settings_dict.get('personal_model_endpoints', []) or [], + _filter_chat_model_endpoints_by_governance(user_id, user_settings_dict.get('personal_model_endpoints', []) or [], 'governance_user_endpoints'), 'personal', user_id, 'Personal' @@ -181,7 +230,7 @@ def append_models(endpoints, scope_type, scope_id=None, scope_name=None): if not group_id: continue append_models( - get_group_model_endpoints(group_id), + _filter_chat_model_endpoints_by_governance(user_id, get_group_model_endpoints(group_id), 'governance_group_endpoints'), 'group', group_id, group_doc.get('name', 'Unnamed Group') @@ -280,7 +329,8 @@ def chats(): multi_endpoint_models = [] if enable_multi_model_endpoints: - endpoints = public_settings.get("model_endpoints", []) or [] + endpoints = _filter_chat_model_endpoints_by_governance(user_id, settings.get("model_endpoints", []) or [], 'governance_global_endpoints') + endpoints = sanitize_model_endpoints_for_frontend(endpoints) for endpoint in endpoints: if not endpoint.get("enabled", True): continue @@ -326,12 +376,14 @@ def chats(): if settings.get('allow_user_agents', False): ensure_migration_complete(user_id) for agent in get_personal_agents(user_id): - chat_agent_options.append(_serialize_chat_agent_option(agent)) + if _is_chat_agent_allowed_by_governance(user_id, agent, 'personal'): + chat_agent_options.append(_serialize_chat_agent_option(agent)) merge_global = settings.get('per_user_semantic_kernel', False) and settings.get('merge_global_semantic_kernel_with_workspace', False) if merge_global: for agent in get_global_agents(): - chat_agent_options.append(_serialize_chat_agent_option(agent, is_global=True)) + if _is_chat_agent_allowed_by_governance(user_id, agent, 'global'): + chat_agent_options.append(_serialize_chat_agent_option(agent, is_global=True)) if settings.get('enable_group_workspaces', False) and settings.get('allow_group_agents', False): for group_doc in user_groups_raw: @@ -340,14 +392,15 @@ def chats(): continue group_name = group_doc.get('name', 'Unnamed Group') for agent in get_group_agents(group_id): - chat_agent_options.append( - _serialize_chat_agent_option( - agent, - is_group=True, - group_id=group_id, - group_name=group_name, + if _is_chat_agent_allowed_by_governance(user_id, agent, 'group'): + chat_agent_options.append( + _serialize_chat_agent_option( + agent, + is_group=True, + group_id=group_id, + group_name=group_name, + ) ) - ) except Exception as e: logger.warning(f"Failed to load chat agent options: {e}") diff --git a/application/single_app/route_frontend_group_workspaces.py b/application/single_app/route_frontend_group_workspaces.py index 6e3186f62..27b661853 100644 --- a/application/single_app/route_frontend_group_workspaces.py +++ b/application/single_app/route_frontend_group_workspaces.py @@ -3,6 +3,7 @@ from config import * from functions_authentication import * from functions_group import get_group_model_endpoints, require_active_group, update_active_group_for_user +from functions_governance import filter_governed_model_endpoints, is_governance_access_allowed from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -57,11 +58,19 @@ def group_workspaces(): )) allowed_extensions_str = "Allowed: " + ", ".join(allowed_extensions) + workspace_governance = { + "group_agents": is_governance_access_allowed("governance_group_agents", user_id), + "group_actions": is_governance_access_allowed("governance_group_actions", user_id), + "group_endpoints": is_governance_access_allowed("governance_group_endpoints", user_id), + "global_endpoints": is_governance_access_allowed("governance_global_endpoints", user_id), + } + + group_endpoints = get_group_model_endpoints(active_group_id) if active_group_id else [] group_model_endpoints = sanitize_model_endpoints_for_frontend( - get_group_model_endpoints(active_group_id) if active_group_id else [] + filter_governed_model_endpoints(user_id, group_endpoints, "governance_group_endpoints") ) global_model_endpoints = sanitize_model_endpoints_for_frontend( - settings.get("model_endpoints", []) + filter_governed_model_endpoints(user_id, settings.get("model_endpoints", []), "governance_global_endpoints") ) # Build allowed extensions string @@ -87,7 +96,8 @@ def group_workspaces(): legacy_docs_count=legacy_count, allowed_extensions=allowed_extensions_str, group_model_endpoints=group_model_endpoints, - global_model_endpoints=global_model_endpoints + global_model_endpoints=global_model_endpoints, + workspace_governance=workspace_governance ) @app.route('/set_active_group', methods=['POST']) diff --git a/application/single_app/route_frontend_workspace.py b/application/single_app/route_frontend_workspace.py index 98ccb65af..4bab89a42 100644 --- a/application/single_app/route_frontend_workspace.py +++ b/application/single_app/route_frontend_workspace.py @@ -2,6 +2,7 @@ from config import * from functions_authentication import * +from functions_governance import filter_governed_model_endpoints, is_governance_access_allowed from functions_settings import * from swagger_wrapper import swagger_route, get_auth_security @@ -51,10 +52,19 @@ def workspace(): )) allowed_extensions_str = "Allowed: " + ", ".join(allowed_extensions) + workspace_governance = { + "user_agents": is_governance_access_allowed("governance_user_agents", user_id), + "user_actions": is_governance_access_allowed("governance_user_actions", user_id), + "user_endpoints": is_governance_access_allowed("governance_user_endpoints", user_id), + "global_endpoints": is_governance_access_allowed("governance_global_endpoints", user_id), + } + personal_endpoints = user_settings.get("settings", {}).get("personal_model_endpoints", []) - personal_model_endpoints = sanitize_model_endpoints_for_frontend(personal_endpoints) + personal_model_endpoints = sanitize_model_endpoints_for_frontend( + filter_governed_model_endpoints(user_id, personal_endpoints, "governance_user_endpoints") + ) global_model_endpoints = sanitize_model_endpoints_for_frontend( - settings.get("model_endpoints", []) + filter_governed_model_endpoints(user_id, settings.get("model_endpoints", []), "governance_global_endpoints") ) return render_template( @@ -68,7 +78,8 @@ def workspace(): legacy_docs_count=legacy_count, allowed_extensions=allowed_extensions_str, personal_model_endpoints=personal_model_endpoints, - global_model_endpoints=global_model_endpoints + global_model_endpoints=global_model_endpoints, + workspace_governance=workspace_governance ) \ No newline at end of file diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 6474185b0..eeee60080 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -87,23 +87,21 @@ body.sidebar-collapsed #chat-sidebar-inline-toggle { z-index: 1060 !important; } -/* Handle dropdown positioning at the edge of viewport */ -#document-dropdown.dropup .dropdown-menu { - bottom: 100% !important; - top: auto !important; - margin-bottom: 0.125rem; -} - #document-dropdown .dropdown-menu { - width: auto; - min-width: 400px; /* Increased width as requested */ - max-width: 400px; /* Increased width as requested */ - max-height: 60vh; /* Use viewport height for better adaptation */ + width: max-content; + min-width: min(20rem, calc(100vw - 2rem)); + max-width: min(40rem, calc(100vw - 2rem)); + max-height: 60vh; overflow: hidden; padding: 8px; - position: absolute; - left: 0 !important; /* Keep it aligned to the left of its container */ - right: auto !important; /* Prevent right positioning */ +} + +.chat-search-filter-menu { + width: max-content; + min-width: min(20rem, calc(100vw - 2rem)); + max-width: min(40rem, calc(100vw - 2rem)); + max-height: 60vh; + overflow: hidden; } .chat-search-panel { @@ -593,10 +591,20 @@ body.sidebar-collapsed #chat-sidebar-inline-toggle { #scope-dropdown-items .dropdown-item, #tags-dropdown-items .dropdown-item { + min-width: 0; width: 100%; text-align: left; } +#scope-dropdown-items .dropdown-item span, +#tags-dropdown-items .dropdown-item span, +#document-dropdown-items .dropdown-item span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Document dropdown items must be explicitly displayed */ #document-dropdown .dropdown-item { display: block !important; @@ -608,6 +616,7 @@ body.sidebar-collapsed #chat-sidebar-inline-toggle { width: 100%; /* Ensure items take full width of their container */ text-align: left; max-width: 100%; /* Ensure items don't exceed container width */ + min-width: 0; } /* Only hide when explicitly told to */ diff --git a/application/single_app/static/css/navigation.css b/application/single_app/static/css/navigation.css index 3b17d9dc0..866361ece 100644 --- a/application/single_app/static/css/navigation.css +++ b/application/single_app/static/css/navigation.css @@ -135,6 +135,40 @@ body { background: rgba(13, 110, 253, 0.08); } +.custom-pages-dropdown-menu { + max-height: min(24rem, 70vh); + overflow-y: auto; +} + +.custom-pages-mobile-list { + max-height: min(24rem, 55vh); + overflow-y: auto; + padding-right: 0.25rem; +} + +.custom-pages-top-drawer-trigger { + align-items: center; + border-radius: 999px; + display: inline-flex; + gap: 0.25rem; + text-decoration: none; +} + +.custom-pages-drawer { + max-width: min(92vw, 26rem); +} + +.custom-pages-drawer-list { + max-height: calc(100vh - 9rem); + overflow-y: auto; +} + +.custom-pages-drawer-item { + align-items: center; + display: flex; + gap: 0.25rem; +} + .top-nav-user-nav { align-items: center; flex-direction: row; diff --git a/application/single_app/static/css/sidebar.css b/application/single_app/static/css/sidebar.css index ab7e652ce..bcaf45934 100644 --- a/application/single_app/static/css/sidebar.css +++ b/application/single_app/static/css/sidebar.css @@ -294,6 +294,52 @@ body.sidebar-nav-enabled.has-classification-banner .container-fluid { padding-bottom: 0.25rem !important; } +/* Custom Pages navigation menu */ +.custom-pages-menu-toggle, +.custom-pages-inline-label { + border: 0; + cursor: pointer; + font-weight: 600; + letter-spacing: 0.02em; + padding: 0.35rem 0.5rem 0.35rem 1rem; + user-select: none; +} + +.custom-pages-menu-toggle { + background: rgba(13, 110, 253, 0.06); + border: 1px solid var(--bs-border-color-translucent); + border-radius: 0.5rem; +} + +.custom-pages-menu-list { + max-height: min(18rem, 45vh); + overflow-y: auto; + padding-right: 0.25rem; +} + +.custom-pages-menu-list::-webkit-scrollbar { + width: 6px; +} + +.custom-pages-menu-list::-webkit-scrollbar-thumb { + background: var(--bs-border-color); + border-radius: 999px; +} + +.custom-pages-count-badge { + font-size: 0.65rem; + min-width: 1.5rem; +} + +.custom-pages-caret { + font-size: 0.9em; + transition: transform 0.2s; +} + +[data-bs-theme="dark"] .custom-pages-menu-toggle { + background: rgba(13, 110, 253, 0.14); +} + .sidebar-resize-handle { position: absolute; top: 0; diff --git a/application/single_app/static/js/admin/admin_agents.js b/application/single_app/static/js/admin/admin_agents.js index 6f9c41458..1f6493164 100644 --- a/application/single_app/static/js/admin/admin_agents.js +++ b/application/single_app/static/js/admin/admin_agents.js @@ -201,6 +201,55 @@ async function openAgentModal(agent = null) { modal.show(); } +function makeAgentCopyName(name) { + const baseName = `${String(name || 'agent').trim() || 'agent'}_copy`; + const existingNames = new Set((agents || []).map((agent) => String(agent.name || '').trim().toLowerCase())); + if (!existingNames.has(baseName.toLowerCase())) { + return baseName; + } + + let suffix = 2; + while (existingNames.has(`${baseName}_${suffix}`.toLowerCase())) { + suffix += 1; + } + return `${baseName}_${suffix}`; +} + +function duplicateAgent(agent) { + const duplicate = JSON.parse(JSON.stringify(agent || {})); + delete duplicate.id; + duplicate.name = makeAgentCopyName(agent?.name || 'agent'); + duplicate.display_name = `${agent?.display_name || agent?.name || 'Agent'} Copy`; + openAgentModal(duplicate); + if (window.agentModalStepper) { + window.agentModalStepper.isEditMode = false; + window.agentModalStepper.originalAgent = null; + const title = document.getElementById('agentModalLabel'); + if (title) { + title.textContent = 'Add Agent'; + } + } + window.editingAgentIndex = null; + window.editingAgentName = null; +} + +function governAgent(agent) { + const agentId = String(agent?.id || '').trim(); + if (!agentId) { + showToast('This agent does not have a stable ID for governance.', 'warning'); + return; + } + if (typeof window.openGovernanceDelegatedItemEditor === 'function') { + window.openGovernanceDelegatedItemEditor({ + entityType: 'global_agent', + itemId: agentId, + resourceLabel: agent.display_name || agent.name || agentId, + }); + } else { + showToast('Governance editor is still loading. Try again in a moment.', 'warning'); + } +} + // Utility: Show agent modal error function showAgentModalError(msg) { const errDiv = document.getElementById('agent-modal-error'); @@ -279,20 +328,47 @@ function renderAgentsTable() { // Use global_selected_agent for badge logic (compare by name) const isSelected = selectedAgent && agent.name === selectedAgent; const tr = document.createElement('tr'); - let selectedBadge = isSelected ? 'Selected' : ''; - const safeName = escapeHtml(agent.name || ''); - const safeDisplayName = escapeHtml(agent.display_name || ''); - const safeDescription = escapeHtml(agent.description || ''); - tr.innerHTML = ` - ${safeName} - ${safeDisplayName} - ${safeDescription} - ${selectedBadge} - - - - - `; + + const nameCell = document.createElement('td'); + nameCell.textContent = agent.name || ''; + + const displayNameCell = document.createElement('td'); + displayNameCell.textContent = agent.display_name || ''; + + const descriptionCell = document.createElement('td'); + descriptionCell.textContent = agent.description || ''; + + const selectedCell = document.createElement('td'); + if (isSelected) { + const selectedBadge = document.createElement('span'); + selectedBadge.className = 'badge bg-primary ms-1'; + selectedBadge.textContent = 'Selected'; + selectedCell.appendChild(selectedBadge); + } + + const actionsCell = document.createElement('td'); + const createAgentButton = (className, text, disabled = false) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = className; + button.dataset.index = String(idx); + button.textContent = text; + button.disabled = disabled; + return button; + }; + actionsCell.appendChild(createAgentButton('btn btn-sm btn-secondary edit-agent-btn', 'Edit')); + actionsCell.appendChild(document.createTextNode(' ')); + actionsCell.appendChild(createAgentButton('btn btn-sm btn-outline-info govern-agent-btn', 'Govern')); + actionsCell.appendChild(document.createTextNode(' ')); + actionsCell.appendChild(createAgentButton('btn btn-sm btn-outline-secondary duplicate-agent-btn', 'Duplicate')); + actionsCell.appendChild(document.createTextNode(' ')); + actionsCell.appendChild(createAgentButton('btn btn-sm btn-danger delete-agent-btn', 'Delete', isSelected)); + + tr.appendChild(nameCell); + tr.appendChild(displayNameCell); + tr.appendChild(descriptionCell); + tr.appendChild(selectedCell); + tr.appendChild(actionsCell); agentsTableBody.appendChild(tr); }); // Attach event listeners for edit and delete buttons (event delegation) @@ -316,6 +392,16 @@ function handleAgentTableClick(e) { deleteAgent(idx); } } + } else if (e.target.classList.contains('govern-agent-btn')) { + const idx = parseInt(e.target.getAttribute('data-index'), 10); + if (!isNaN(idx) && Array.isArray(agents)) { + governAgent(agents[idx]); + } + } else if (e.target.classList.contains('duplicate-agent-btn')) { + const idx = parseInt(e.target.getAttribute('data-index'), 10); + if (!isNaN(idx) && Array.isArray(agents)) { + duplicateAgent(agents[idx]); + } } } diff --git a/application/single_app/static/js/admin/admin_custom_pages.js b/application/single_app/static/js/admin/admin_custom_pages.js new file mode 100644 index 000000000..a55685dfc --- /dev/null +++ b/application/single_app/static/js/admin/admin_custom_pages.js @@ -0,0 +1,631 @@ +// admin_custom_pages.js +import { showToast } from "../chat/chat-toast.js"; + +let customPages = Array.isArray(window.customPages) ? window.customPages : []; + +const enableCustomPagesToggle = document.getElementById("enable_custom_pages"); +const customPagesSettings = document.getElementById("custom_pages_settings"); +const customPagesTbody = document.getElementById("custom-pages-tbody"); +const customPageModalElement = document.getElementById("customPageDesignerModal"); +const customPageForm = document.getElementById("custom-page-designer-form"); +const addCustomPageButton = document.getElementById("add-custom-page-btn"); +const createRequestAccessPageButton = document.getElementById("create-request-access-page-btn"); +const customPageModalTitle = document.getElementById("customPageDesignerModalLabel"); +const customPageSaveButton = document.querySelector("[form='custom-page-designer-form'][type='submit']"); +const restartAcknowledgementField = document.getElementById("custom_pages_restart_acknowledged"); +const restartModalElement = document.getElementById("customPagesRestartModal"); +const restartAcknowledgeButton = document.getElementById("custom-pages-restart-acknowledge-btn"); +const restartCancelButton = document.getElementById("custom-pages-restart-cancel-btn"); +const guideButton = document.getElementById("custom-pages-guide-btn"); +const guideModalElement = document.getElementById("customPagesGuideModal"); +const guideStatus = document.getElementById("custom-pages-guide-status"); +const guideContent = document.getElementById("custom-pages-guide-content"); +const requestAccessCreatedModalElement = document.getElementById("requestAccessPageCreatedModal"); +const customPageSlugField = document.getElementById("custom_page_slug"); +const customPageSlugFeedback = document.getElementById("custom-page-slug-feedback"); + +let customPageModal = null; +let restartModal = null; +let guideModal = null; +let requestAccessCreatedModal = null; +let guideMarkdownLoaded = false; +const fileListFields = ["css_files", "js_files", "asset_files", "json_files"]; +const fileListControls = { + css_files: { + inputId: "custom_page_css_file_entry", + addButtonId: "custom_page_css_file_add", + hiddenId: "custom_page_css_files", + listId: "custom_page_css_files_list" + }, + js_files: { + inputId: "custom_page_js_file_entry", + addButtonId: "custom_page_js_file_add", + hiddenId: "custom_page_js_files", + listId: "custom_page_js_files_list" + }, + asset_files: { + inputId: "custom_page_asset_file_entry", + addButtonId: "custom_page_asset_file_add", + hiddenId: "custom_page_asset_files", + listId: "custom_page_asset_files_list" + }, + json_files: { + inputId: "custom_page_json_file_entry", + addButtonId: "custom_page_json_file_add", + hiddenId: "custom_page_json_files", + listId: "custom_page_json_files_list" + } +}; +const customPageFileLists = { + css_files: [], + js_files: [], + asset_files: [], + json_files: [] +}; + +document.addEventListener("DOMContentLoaded", () => { + if (!customPagesTbody) { + return; + } + + if (customPageModalElement && typeof bootstrap !== "undefined" && bootstrap.Modal) { + customPageModal = bootstrap.Modal.getOrCreateInstance(customPageModalElement); + } + if (restartModalElement && typeof bootstrap !== "undefined" && bootstrap.Modal) { + restartModal = bootstrap.Modal.getOrCreateInstance(restartModalElement); + } + if (guideModalElement && typeof bootstrap !== "undefined" && bootstrap.Modal) { + guideModal = bootstrap.Modal.getOrCreateInstance(guideModalElement); + } + if (requestAccessCreatedModalElement && typeof bootstrap !== "undefined" && bootstrap.Modal) { + requestAccessCreatedModal = bootstrap.Modal.getOrCreateInstance(requestAccessCreatedModalElement); + } + + setupCustomPagesToggle(); + setupCustomPagesGuide(); + setupCustomPageFileListEditors(); + setupCustomPageSlugValidation(); + renderCustomPagesTable(); + refreshCustomPages(); + + if (addCustomPageButton) { + addCustomPageButton.addEventListener("click", () => openCustomPageDesigner()); + } + + if (createRequestAccessPageButton) { + createRequestAccessPageButton.addEventListener("click", createRequestAccessPage); + } + + if (customPageForm) { + customPageForm.addEventListener("submit", saveCustomPageFromDesigner); + } +}); + +function setupCustomPagesToggle() { + if (!enableCustomPagesToggle || !customPagesSettings) { + return; + } + + const initiallyEnabled = window.customPagesInitiallyEnabled === true; + + function syncCustomPagesVisibility() { + customPagesSettings.classList.toggle("d-none", !enableCustomPagesToggle.checked); + } + + enableCustomPagesToggle.addEventListener("change", () => { + if (restartAcknowledgementField && !enableCustomPagesToggle.checked) { + restartAcknowledgementField.value = ""; + } + + syncCustomPagesVisibility(); + + if (enableCustomPagesToggle.checked && !initiallyEnabled && restartModal) { + restartModal.show(); + } + }); + + if (restartAcknowledgeButton) { + restartAcknowledgeButton.addEventListener("click", () => { + if (restartAcknowledgementField) { + restartAcknowledgementField.value = "on"; + } + if (restartModal) { + restartModal.hide(); + } + }); + } + + if (restartCancelButton) { + restartCancelButton.addEventListener("click", () => { + enableCustomPagesToggle.checked = false; + if (restartAcknowledgementField) { + restartAcknowledgementField.value = ""; + } + syncCustomPagesVisibility(); + if (restartModal) { + restartModal.hide(); + } + }); + } + + syncCustomPagesVisibility(); +} + +async function refreshCustomPages() { + try { + const response = await fetch("/api/admin/custom-pages"); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || "Failed to load custom pages."); + } + customPages = Array.isArray(payload.pages) ? payload.pages : []; + renderCustomPagesTable(); + syncRequestAccessPageButtonState(); + } catch (error) { + showToast(`Custom pages could not be loaded: ${error.message}`, "warning"); + } +} + +function renderCustomPagesTable() { + if (!customPagesTbody) { + return; + } + + customPagesTbody.textContent = ""; + + if (!customPages.length) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = 8; + cell.className = "text-muted text-center"; + cell.textContent = "No custom pages are registered."; + row.appendChild(cell); + customPagesTbody.appendChild(row); + return; + } + + customPages.forEach(page => { + const row = document.createElement("tr"); + appendTextCell(row, page.slug || ""); + appendTextCell(row, page.title || ""); + appendTextCell(row, page.entry_type || "static"); + appendTextCell(row, page.access_level === "authenticated" ? "Any signed-in user" : "App users only"); + appendTextCell(row, Array.isArray(page.roles) && page.roles.length ? page.roles.join(", ") : "Any eligible user"); + appendTextCell(row, page.enabled ? "Enabled" : "Disabled"); + appendTextCell(row, page.show_in_nav ? "Shown" : "Hidden"); + + const actionCell = document.createElement("td"); + const isPythonPage = page.source === "python"; + + const editButton = document.createElement("button"); + editButton.type = "button"; + editButton.className = "btn btn-sm btn-outline-primary me-1"; + editButton.textContent = isPythonPage ? "View" : "Edit"; + editButton.addEventListener("click", () => openCustomPageDesigner(page, isPythonPage)); + actionCell.appendChild(editButton); + + if (!isPythonPage) { + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "btn btn-sm btn-outline-danger"; + deleteButton.textContent = "Delete"; + deleteButton.addEventListener("click", () => deleteCustomPage(page.slug)); + actionCell.appendChild(deleteButton); + } + + row.appendChild(actionCell); + customPagesTbody.appendChild(row); + }); + syncRequestAccessPageButtonState(); +} + +function requestAccessPageExists() { + return customPages.some(page => (page.slug || "").toLowerCase() === "request-access"); +} + +function syncRequestAccessPageButtonState() { + if (!createRequestAccessPageButton) { + return; + } + + const exists = requestAccessPageExists(); + createRequestAccessPageButton.disabled = exists; + createRequestAccessPageButton.title = exists + ? "The request-access custom page already exists." + : "Create the optional Request Access page metadata."; +} + +function setupCustomPageSlugValidation() { + if (!customPageSlugField) { + return; + } + + customPageSlugField.addEventListener("blur", validateCustomPageSlugUniqueness); + customPageSlugField.addEventListener("input", () => { + customPageSlugField.classList.remove("is-invalid"); + if (customPageSlugFeedback) { + customPageSlugFeedback.textContent = "A custom page with this slug already exists."; + } + }); +} + +function getNormalizedSlugValue() { + return (customPageSlugField?.value || "").trim().toLowerCase(); +} + +function customPageSlugExists(slug) { + if (!slug) { + return false; + } + + const originalSlug = customPageForm?.dataset.originalSlug || ""; + return customPages.some(page => { + const pageSlug = (page.slug || "").toLowerCase(); + return pageSlug === slug && pageSlug !== originalSlug; + }); +} + +function validateCustomPageSlugUniqueness() { + if (!customPageSlugField) { + return true; + } + + const slug = getNormalizedSlugValue(); + const isDuplicate = customPageSlugExists(slug); + customPageSlugField.classList.toggle("is-invalid", isDuplicate); + if (isDuplicate && customPageSlugFeedback) { + customPageSlugFeedback.textContent = `A custom page with slug "${slug}" already exists.`; + } + return !isDuplicate; +} + +function appendTextCell(row, value) { + const cell = document.createElement("td"); + cell.textContent = value; + row.appendChild(cell); +} + +function setupCustomPageFileListEditors() { + fileListFields.forEach(fieldName => { + const controls = fileListControls[fieldName]; + const addButton = document.getElementById(controls.addButtonId); + const input = document.getElementById(controls.inputId); + + if (addButton) { + addButton.addEventListener("click", () => addCustomPageFile(fieldName)); + } + + if (input) { + input.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + addCustomPageFile(fieldName); + } + }); + } + + setFileListValues(fieldName, []); + }); +} + +function addCustomPageFile(fieldName) { + const controls = fileListControls[fieldName]; + const input = controls ? document.getElementById(controls.inputId) : null; + if (!input) { + return; + } + + const fileName = input.value.trim(); + if (!fileName) { + return; + } + + const currentFiles = customPageFileLists[fieldName] || []; + if (!currentFiles.includes(fileName)) { + currentFiles.push(fileName); + setFileListValues(fieldName, currentFiles); + } + + input.value = ""; + input.focus(); +} + +function removeCustomPageFile(fieldName, fileName) { + const currentFiles = customPageFileLists[fieldName] || []; + setFileListValues(fieldName, currentFiles.filter(currentFileName => currentFileName !== fileName)); +} + +function setFileListValues(fieldName, values) { + customPageFileLists[fieldName] = normalizeFileList(values); + syncFileListHiddenField(fieldName); + renderCustomPageFileList(fieldName); +} + +function normalizeFileList(values) { + if (!Array.isArray(values)) { + return []; + } + + const seen = new Set(); + const normalizedValues = []; + values.forEach(value => { + const fileName = String(value || "").trim(); + if (fileName && !seen.has(fileName)) { + seen.add(fileName); + normalizedValues.push(fileName); + } + }); + return normalizedValues; +} + +function syncFileListHiddenField(fieldName) { + const controls = fileListControls[fieldName]; + const hiddenField = controls ? document.getElementById(controls.hiddenId) : null; + if (hiddenField) { + hiddenField.value = (customPageFileLists[fieldName] || []).join(", "); + } +} + +function renderCustomPageFileList(fieldName) { + const controls = fileListControls[fieldName]; + const listElement = controls ? document.getElementById(controls.listId) : null; + if (!listElement) { + return; + } + + listElement.textContent = ""; + const files = customPageFileLists[fieldName] || []; + + if (!files.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "list-group-item text-muted small"; + emptyItem.textContent = "No files added."; + listElement.appendChild(emptyItem); + return; + } + + files.forEach(fileName => { + const item = document.createElement("li"); + item.className = "list-group-item d-flex align-items-center justify-content-between gap-2"; + + const label = document.createElement("span"); + label.className = "text-break"; + label.textContent = fileName; + item.appendChild(label); + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "btn btn-sm btn-outline-danger"; + removeButton.textContent = "Remove"; + removeButton.addEventListener("click", () => removeCustomPageFile(fieldName, fileName)); + item.appendChild(removeButton); + + listElement.appendChild(item); + }); +} + +function openCustomPageDesigner(page = null, readOnly = false) { + if (!customPageForm || !customPageModal) { + return; + } + + customPageForm.reset(); + customPageForm.dataset.mode = page ? "edit" : "create"; + customPageForm.dataset.originalSlug = page?.slug || ""; + if (customPageSlugField) { + customPageSlugField.classList.remove("is-invalid"); + } + + if (customPageModalTitle) { + customPageModalTitle.textContent = readOnly ? "View Custom Page" : (page ? "Edit Custom Page" : "New Custom Page"); + } + + setFieldValue("custom_page_slug", page?.slug || ""); + setFieldValue("custom_page_title", page?.title || ""); + setFieldValue("custom_page_description", page?.description || ""); + setFieldValue("custom_page_nav_label", page?.nav_label || page?.title || ""); + setFieldValue("custom_page_nav_icon", page?.nav_icon || "bi-file-earmark-text"); + setFieldValue("custom_page_nav_order", page?.nav_order ?? 100); + setFieldValue("custom_page_roles", Array.isArray(page?.roles) ? page.roles.join(", ") : ""); + setFieldValue("custom_page_html_file", page?.html_file || ""); + setFieldValue("custom_page_access_level", page?.access_level || "app_user"); + setFileListValues("css_files", Array.isArray(page?.css_files) ? page.css_files : []); + setFileListValues("js_files", Array.isArray(page?.js_files) ? page.js_files : []); + setFileListValues("asset_files", Array.isArray(page?.asset_files) ? page.asset_files : []); + setFileListValues("json_files", Array.isArray(page?.json_files) ? page.json_files : []); + setCheckedValue("custom_page_enabled", page?.enabled !== false); + setCheckedValue("custom_page_show_in_nav", page?.show_in_nav !== false); + setCheckedValue("custom_page_open_in_new_tab", page?.open_in_new_tab === true); + + customPageForm.querySelectorAll("input, textarea, button").forEach(element => { + element.disabled = readOnly; + }); + if (customPageSaveButton) { + customPageSaveButton.disabled = readOnly; + } + + customPageModal.show(); +} + +function setFieldValue(id, value) { + const field = document.getElementById(id); + if (field) { + field.value = value; + } +} + +function setCheckedValue(id, value) { + const field = document.getElementById(id); + if (field) { + field.checked = Boolean(value); + } +} + +async function saveCustomPageFromDesigner(event) { + event.preventDefault(); + if (!validateCustomPageSlugUniqueness()) { + showToast("Choose a unique slug before saving this custom page.", "warning"); + return; + } + + const originalSlug = customPageForm.dataset.originalSlug || ""; + const payload = readCustomPageDesignerPayload(); + const mode = customPageForm.dataset.mode || "create"; + const url = mode === "edit" && originalSlug + ? `/api/admin/custom-pages/${encodeURIComponent(originalSlug)}` + : "/api/admin/custom-pages"; + const method = mode === "edit" && originalSlug ? "PUT" : "POST"; + + try { + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + const result = response.status === 204 ? {} : await response.json(); + if (!response.ok) { + throw new Error(result.error || "Failed to save custom page."); + } + showToast("Custom page saved.", "success"); + customPageModal.hide(); + await refreshCustomPages(); + } catch (error) { + showToast(error.message, "danger"); + } +} + +function readCustomPageDesignerPayload() { + return { + slug: getFieldValue("custom_page_slug").trim().toLowerCase(), + title: getFieldValue("custom_page_title").trim(), + description: getFieldValue("custom_page_description").trim(), + nav_label: getFieldValue("custom_page_nav_label").trim(), + nav_icon: getFieldValue("custom_page_nav_icon").trim() || "bi-file-earmark-text", + nav_order: Number.parseInt(getFieldValue("custom_page_nav_order"), 10) || 100, + roles: splitCsv(getFieldValue("custom_page_roles")), + access_level: getFieldValue("custom_page_access_level") || "app_user", + html_file: getFieldValue("custom_page_html_file").trim(), + css_files: customPageFileLists.css_files, + js_files: customPageFileLists.js_files, + asset_files: customPageFileLists.asset_files, + json_files: customPageFileLists.json_files, + enabled: Boolean(document.getElementById("custom_page_enabled")?.checked), + show_in_nav: Boolean(document.getElementById("custom_page_show_in_nav")?.checked), + open_in_new_tab: Boolean(document.getElementById("custom_page_open_in_new_tab")?.checked), + entry_type: "static" + }; +} + +function getFieldValue(id) { + return document.getElementById(id)?.value || ""; +} + +function splitCsv(value) { + return String(value || "") + .split(",") + .map(item => item.trim()) + .filter(Boolean); +} + +async function deleteCustomPage(slug) { + if (!slug) { + return; + } + + try { + const response = await fetch(`/api/admin/custom-pages/${encodeURIComponent(slug)}`, { + method: "DELETE" + }); + if (!response.ok) { + const payload = await response.json(); + throw new Error(payload.error || "Failed to delete custom page."); + } + showToast("Custom page deleted.", "success"); + await refreshCustomPages(); + } catch (error) { + showToast(error.message, "danger"); + } +} + +function setupCustomPagesGuide() { + if (!guideButton || !guideModal) { + return; + } + + guideButton.addEventListener("click", async () => { + guideModal.show(); + if (!guideMarkdownLoaded) { + await loadCustomPagesGuide(); + } + }); +} + +async function loadCustomPagesGuide() { + if (guideStatus) { + guideStatus.classList.remove("d-none"); + guideStatus.textContent = "Loading guide..."; + } + if (guideContent) { + guideContent.classList.add("d-none"); + guideContent.textContent = ""; + } + + try { + const response = await fetch("/api/admin/custom-pages/developer-guide", { credentials: "same-origin" }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || "Developer guide could not be loaded."); + } + + const markdown = payload.markdown || ""; + if (guideContent && typeof marked !== "undefined" && typeof DOMPurify !== "undefined") { + guideContent.innerHTML = DOMPurify.sanitize(marked.parse(markdown)); + guideContent.classList.remove("d-none"); + guideMarkdownLoaded = true; + if (guideStatus) { + guideStatus.classList.add("d-none"); + } + return; + } + + if (guideContent) { + guideContent.textContent = markdown; + guideContent.classList.remove("d-none"); + guideMarkdownLoaded = true; + } + if (guideStatus) { + guideStatus.classList.add("d-none"); + } + } catch (error) { + if (guideStatus) { + guideStatus.textContent = error.message; + } + } +} + +async function createRequestAccessPage() { + if (!createRequestAccessPageButton) { + return; + } + + createRequestAccessPageButton.disabled = true; + try { + const response = await fetch("/api/admin/custom-pages/request-access-example", { + method: "POST", + headers: { "Content-Type": "application/json" } + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || "Failed to create Request Access page."); + } + showToast("Request Access page created and access-denied button enabled.", "success"); + await refreshCustomPages(); + if (requestAccessCreatedModal) { + requestAccessCreatedModal.show(); + } + } catch (error) { + showToast(error.message, "danger"); + } finally { + syncRequestAccessPageButtonState(); + } +} \ No newline at end of file diff --git a/application/single_app/static/js/admin/admin_governance.js b/application/single_app/static/js/admin/admin_governance.js new file mode 100644 index 000000000..c243b0b14 --- /dev/null +++ b/application/single_app/static/js/admin/admin_governance.js @@ -0,0 +1,3466 @@ +// admin_governance.js + +const GOVERNANCE_FEATURE_LABELS = { + governance_user_endpoints: 'User Endpoints', + governance_group_endpoints: 'Group Endpoints', + governance_global_endpoints: 'Global Endpoints', + governance_user_agents: 'User Agents', + governance_group_agents: 'Group Agents', + governance_global_agents_usage: 'Global Agent Usage', + governance_user_actions: 'User Actions', + governance_group_actions: 'Group Actions', + governance_global_actions_usage: 'Global Action Usage', +}; + +const GOVERNANCE_ITEM_ENTITY_LABELS = { + global_endpoint: 'Global Endpoint', + global_agent: 'Global Agent', + global_action: 'Global Action', +}; + +const GOVERNANCE_ITEM_LOOKUP_HINTS = { + global_endpoint: 'Select an endpoint configured in Admin Settings.', + global_agent: 'Select a global agent available for delegation.', + global_action: 'Select a global action available for delegation.', +}; + +const GOVERNANCE_PRIMARY_TOGGLE_MAP = { + governance_user_endpoints: 'toggle-allow-user-custom-endpoints', + governance_group_endpoints: 'toggle-allow-group-custom-endpoints', + governance_user_agents: 'toggle-allow-user-agents', + governance_group_agents: 'toggle-allow-group-agents', + governance_global_agents_usage: 'toggle-enable-agents-agents', + governance_user_actions: 'toggle-allow-user-plugins', + governance_group_actions: 'toggle-allow-group-plugins', + governance_global_actions_usage: 'toggle-enable-agents-agents', +}; + +const GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT = 50; +const GOVERNANCE_ALLOWLIST_PAGE_SIZES = [10, 25, 50, 100]; +const GOVERNANCE_ALLOWLIST_TRUNCATE_ID_LENGTH = 35; + +const GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE = 25; +const GOVERNANCE_ITEM_EDITOR_PAGE_SIZE_DEFAULT = 50; + +const governanceItemReviewState = { + search: '', + entityType: '', + page: 1, + perPage: GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE, +}; + +let governanceAllowListEditorModal = null; +let governanceAllowListEditorContext = null; +let governanceItemPolicyDeleteModal = null; +let governanceItemPolicyDeleteContext = null; +let governanceItemPolicyEditorModal = null; +const governanceItemLookupState = { + global_endpoint: [], + global_agent: [], + global_action: [], +}; + +const governanceAllowListSelectionViewState = { + users: { + search: '', + page: 1, + pageSize: GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT, + }, + groups: { + search: '', + page: 1, + pageSize: GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT, + }, +}; + +const governanceItemEditorSelectionViewState = { + users: { + search: '', + page: 1, + pageSize: GOVERNANCE_ITEM_EDITOR_PAGE_SIZE_DEFAULT, + }, + groups: { + search: '', + page: 1, + pageSize: GOVERNANCE_ITEM_EDITOR_PAGE_SIZE_DEFAULT, + }, +}; + +const governanceAllowListDisplayNameCache = { + users: {}, + groups: {}, +}; + +const governanceAllowListHydrationState = { + users: new Set(), + groups: new Set(), +}; + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function setBootstrapIcon(element, iconClass) { + element.replaceChildren(); + const icon = document.createElement('i'); + icon.className = iconClass; + element.appendChild(icon); +} + +function splitPrincipalList(value) { + if (!value) { + return []; + } + + return String(value) + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +function joinPrincipalList(values) { + if (!Array.isArray(values) || values.length === 0) { + return ''; + } + + return values.join(', '); +} + +function parseCsvPrincipalLines(csvText) { + if (!csvText) { + return []; + } + + return String(csvText) + .split(/\r?\n/) + .flatMap((line) => line.split(',')) + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function uniquePrincipalList(values) { + return Array.from(new Set((Array.isArray(values) ? values : []).map((value) => String(value || '').trim()).filter((value) => value))); +} + +function buildAllowListSummary(users, groups) { + const usersCount = Array.isArray(users) ? users.length : 0; + const groupsCount = Array.isArray(groups) ? groups.length : 0; + if (usersCount === 0 && groupsCount === 0) { + return 'No explicit users or groups configured'; + } + return `${usersCount} user${usersCount === 1 ? '' : 's'}, ${groupsCount} group${groupsCount === 1 ? '' : 's'}`; +} + +function getGovernanceUsersInputForFeatureRow(row) { + return row?.querySelector('.governance-allowed-users') || null; +} + +function getGovernanceGroupsInputForFeatureRow(row) { + return row?.querySelector('.governance-allowed-groups') || null; +} + +function getGovernanceFeatureAllowAllInput(row) { + return row?.querySelector('.governance-allow-all') || null; +} + +function getItemAllowAllInput() { + return document.getElementById('governance-item-allow-all'); +} + +function getItemUsersInput() { + return document.getElementById('governance-item-users'); +} + +function getItemGroupsInput() { + return document.getElementById('governance-item-groups'); +} + +function getItemEntityTypeInput() { + return document.getElementById('governance-item-entity-type'); +} + +function getItemIdInput() { + return document.getElementById('governance-item-id'); +} + +function getItemPolicyIdInput() { + return document.getElementById('governance-item-policy-id'); +} + +function getItemPolicyNameInput() { + return document.getElementById('governance-item-policy-name'); +} + +function getItemResourceLabelInput() { + return document.getElementById('governance-item-resource-label'); +} + +function getItemLookupFilterInput() { + return document.getElementById('governance-item-id-filter'); +} + +function buildDefaultItemPolicyName(entityType, itemId, resourceLabel = '') { + const label = String(resourceLabel || itemId || 'Resource').trim(); + const entityLabel = buildItemPolicyEntityLabel(entityType) || 'Delegated Item'; + return `${label} ${entityLabel} Policy`; +} + +function setGovernanceItemLookupStatus(message, level = 'muted') { + const status = document.getElementById('governance-item-id-status'); + if (!status) { + return; + } + + status.classList.remove('text-muted', 'text-success', 'text-warning', 'text-danger'); + const className = { + muted: 'text-muted', + success: 'text-success', + warning: 'text-warning', + danger: 'text-danger', + }[level] || 'text-muted'; + status.classList.add(className); + status.textContent = message || ''; +} + +function normalizeGovernanceLookupOption(option, fallbackLabelPrefix) { + const value = String(option?.value || option?.id || '').trim(); + if (!value) { + return null; + } + + const label = String(option?.label || option?.name || option?.display_name || `${fallbackLabelPrefix} ${value}`).trim(); + const subtitle = String(option?.subtitle || option?.description || '').trim(); + return { + value, + label, + subtitle, + }; +} + +function buildGovernanceItemLookupOption(option) { + const label = option.subtitle ? `${option.label} (${option.subtitle})` : option.label; + const element = document.createElement('option'); + element.value = option.value; + element.textContent = label; + return element; +} + +function getAdminEndpointLookupOptionsFromWindow() { + const fromWindow = Array.isArray(window.modelEndpoints) ? window.modelEndpoints : []; + const fromHiddenInputRaw = document.getElementById('model_endpoints_json')?.value || '[]'; + + let fromHiddenInput = []; + try { + const parsed = JSON.parse(fromHiddenInputRaw); + fromHiddenInput = Array.isArray(parsed) ? parsed : []; + } catch (error) { + fromHiddenInput = []; + } + + const merged = [...fromWindow, ...fromHiddenInput]; + return merged + .map((endpoint) => normalizeGovernanceLookupOption({ + value: endpoint?.id, + label: endpoint?.name || endpoint?.id, + subtitle: endpoint?.connection?.endpoint || endpoint?.endpoint || '', + }, 'Endpoint')) + .filter((endpoint) => endpoint !== null) + .filter((endpoint, index, arr) => arr.findIndex((candidate) => candidate.value === endpoint.value) === index); +} + +async function fetchAdminGlobalAgentLookupOptions() { + const response = await fetch('/api/admin/agents', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to load global agents lookup.'); + } + + const payload = await response.json(); + return (Array.isArray(payload) ? payload : []) + .map((agent) => normalizeGovernanceLookupOption({ + value: agent?.id, + label: agent?.display_name || agent?.name || agent?.id, + subtitle: agent?.name && agent?.display_name && agent?.display_name !== agent?.name ? agent?.name : '', + }, 'Agent')) + .filter((option) => option !== null); +} + +async function fetchAdminGlobalActionLookupOptions() { + const response = await fetch('/api/admin/plugins', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to load global actions lookup.'); + } + + const payload = await response.json(); + return (Array.isArray(payload) ? payload : []) + .map((action) => normalizeGovernanceLookupOption({ + value: action?.id, + label: action?.name || action?.id, + subtitle: action?.type || '', + }, 'Action')) + .filter((option) => option !== null); +} + +async function loadGovernanceItemLookup(entityType, forceReload = false) { + const normalizedEntityType = normalizeGovernanceItemEntityType(entityType); + if (!normalizedEntityType) { + return []; + } + + if (!forceReload && Array.isArray(governanceItemLookupState[normalizedEntityType]) && governanceItemLookupState[normalizedEntityType].length > 0) { + return governanceItemLookupState[normalizedEntityType]; + } + + if (normalizedEntityType === 'global_endpoint') { + governanceItemLookupState.global_endpoint = getAdminEndpointLookupOptionsFromWindow(); + return governanceItemLookupState.global_endpoint; + } + if (normalizedEntityType === 'global_agent') { + governanceItemLookupState.global_agent = await fetchAdminGlobalAgentLookupOptions(); + return governanceItemLookupState.global_agent; + } + if (normalizedEntityType === 'global_action') { + governanceItemLookupState.global_action = await fetchAdminGlobalActionLookupOptions(); + return governanceItemLookupState.global_action; + } + + return []; +} + +function renderGovernanceItemLookupOptions(entityType, preferredValue = '') { + const itemIdInput = getItemIdInput(); + if (!itemIdInput) { + return; + } + + const options = Array.isArray(governanceItemLookupState[entityType]) ? governanceItemLookupState[entityType] : []; + const currentValue = String(preferredValue || '').trim(); + const filterValue = String(getItemLookupFilterInput()?.value || '').trim().toLowerCase(); + const visibleOptions = filterValue + ? options.filter((option) => { + const optionText = `${option.value || ''} ${option.label || ''} ${option.subtitle || ''}`.toLowerCase(); + return optionText.includes(filterValue); + }) + : options; + + itemIdInput.innerHTML = ''; + + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = visibleOptions.length > 0 ? 'Select an item' : 'No items available'; + itemIdInput.appendChild(placeholder); + + visibleOptions.forEach((option) => { + itemIdInput.appendChild(buildGovernanceItemLookupOption(option)); + }); + + if (currentValue && visibleOptions.some((option) => option.value === currentValue)) { + itemIdInput.value = currentValue; + } else { + itemIdInput.value = ''; + } + + const hint = GOVERNANCE_ITEM_LOOKUP_HINTS[entityType] || 'Select a delegated item.'; + if (options.length > 0) { + const filterSummary = filterValue ? ` Showing ${visibleOptions.length} of ${options.length}.` : ''; + setGovernanceItemLookupStatus(`${hint} Loaded ${options.length} item${options.length === 1 ? '' : 's'}.${filterSummary}`, 'muted'); + } else { + setGovernanceItemLookupStatus(`${hint} No items found for this type.`, 'warning'); + } +} + +async function refreshGovernanceItemLookup(entityType, forceReload = false, preferredValue = '') { + const refreshButton = document.getElementById('governance-item-id-refresh-btn'); + if (refreshButton) { + refreshButton.disabled = true; + } + + try { + await loadGovernanceItemLookup(entityType, forceReload); + renderGovernanceItemLookupOptions(entityType, preferredValue); + } catch (error) { + renderGovernanceItemLookupOptions(entityType, ''); + setGovernanceItemLookupStatus(error.message || 'Failed to load delegated item lookup.', 'danger'); + } finally { + if (refreshButton) { + refreshButton.disabled = false; + } + } +} + +function updateFeatureAllowListSummary(row) { + const usersInput = getGovernanceUsersInputForFeatureRow(row); + const groupsInput = getGovernanceGroupsInputForFeatureRow(row); + const summaryEl = row?.querySelector('.governance-allowlist-summary'); + if (!usersInput || !groupsInput || !summaryEl) { + return; + } + + const users = splitPrincipalList(usersInput.value); + const groups = splitPrincipalList(groupsInput.value); + summaryEl.textContent = buildAllowListSummary(users, groups); +} + +function updateItemAllowListSummary() { + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + const summaryInput = document.getElementById('governance-item-allowlist-summary'); + if (!usersInput || !groupsInput || !summaryInput) { + return; + } + + summaryInput.value = buildAllowListSummary(splitPrincipalList(usersInput.value), splitPrincipalList(groupsInput.value)); +} + +function applyFeatureAllowAllUiState(row) { + const allowAllInput = getGovernanceFeatureAllowAllInput(row); + const editButton = row?.querySelector('.governance-edit-feature-allowlist-btn'); + const usersInput = getGovernanceUsersInputForFeatureRow(row); + const groupsInput = getGovernanceGroupsInputForFeatureRow(row); + if (!allowAllInput || !usersInput || !groupsInput) { + return; + } + + if (allowAllInput.checked) { + usersInput.value = ''; + groupsInput.value = ''; + } + + if (editButton) { + editButton.disabled = allowAllInput.checked; + } + + updateFeatureAllowListSummary(row); +} + +function applyItemAllowAllUiState() { + const allowAllInput = getItemAllowAllInput(); + const allowedPrincipalsControls = document.getElementById('governance-item-allowed-principals-controls'); + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + if (!allowAllInput || !usersInput || !groupsInput) { + return; + } + + if (allowAllInput.checked) { + usersInput.value = ''; + groupsInput.value = ''; + } + + if (allowedPrincipalsControls) { + allowedPrincipalsControls.classList.toggle('d-none', allowAllInput.checked); + } + + updateItemAllowListSummary(); + renderGovernanceItemEditorSelections(); +} + +async function governanceLookupUsers(query) { + const response = await fetch(`/api/userSearch?query=${encodeURIComponent(String(query || '').trim())}`, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('User lookup failed.'); + } + + const payload = await response.json(); + return Array.isArray(payload) ? payload : []; +} + +async function governanceLookupGroups(query) { + const response = await fetch(`/api/groups/discover?search=${encodeURIComponent(String(query || '').trim())}&showAll=true`, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Group lookup failed.'); + } + + const payload = await response.json(); + return Array.isArray(payload) ? payload : []; +} + +// Prep hook for chat/workspace follow-up to reuse the same normalized lookup behavior. +window.governancePrincipalLookup = { + searchUsers: governanceLookupUsers, + searchGroups: governanceLookupGroups, +}; + +function buildItemPolicyEntityLabel(entityType) { + const normalizedEntityType = normalizeGovernanceItemEntityType(entityType); + return GOVERNANCE_ITEM_ENTITY_LABELS[normalizedEntityType] || normalizedEntityType || ''; +} + +function normalizeGovernanceItemEntityType(entityType) { + const normalizedEntityType = String(entityType || '').trim(); + return normalizedEntityType === 'endpoint' ? 'global_endpoint' : normalizedEntityType; +} + +function mapGovernanceLevelToToastVariant(level = 'info') { + const normalized = String(level || 'info').toLowerCase(); + if (normalized === 'error') { + return 'danger'; + } + if (normalized === 'warn') { + return 'warning'; + } + return normalized; +} + +function setGovernanceInlineStatusFallback(message, level = 'info') { + const status = document.getElementById('governance-status'); + if (!status) { + return; + } + + const alertLevel = mapGovernanceLevelToToastVariant(level); + status.className = `alert alert-${alertLevel}`; + status.classList.remove('d-none'); + status.textContent = String(message || ''); +} + +function setGovernanceStatus(message, level = 'info') { + if (!message) { + return; + } + showGovernanceToast(message, mapGovernanceLevelToToastVariant(level)); +} + +function clearGovernanceStatus() { + const status = document.getElementById('governance-status'); + if (!status) { + return; + } + + status.className = 'alert d-none'; + status.textContent = ''; +} + +function showGovernanceToast(message, variant = 'success') { + const normalizedVariant = mapGovernanceLevelToToastVariant(variant); + const container = document.getElementById('toast-container'); + if (!container || typeof bootstrap?.Toast !== 'function') { + setGovernanceInlineStatusFallback(message, normalizedVariant === 'danger' ? 'danger' : normalizedVariant); + return; + } + + const toastEl = document.createElement('div'); + toastEl.className = `toast align-items-center text-bg-${normalizedVariant}`; + toastEl.setAttribute('role', 'alert'); + toastEl.setAttribute('aria-live', 'assertive'); + toastEl.setAttribute('aria-atomic', 'true'); + + const contentEl = document.createElement('div'); + contentEl.className = 'd-flex'; + + const bodyEl = document.createElement('div'); + bodyEl.className = 'toast-body'; + bodyEl.textContent = String(message || ''); + + const closeButtonEl = document.createElement('button'); + closeButtonEl.type = 'button'; + closeButtonEl.className = 'btn-close btn-close-white me-2 m-auto'; + closeButtonEl.setAttribute('data-bs-dismiss', 'toast'); + closeButtonEl.setAttribute('aria-label', 'Close'); + + contentEl.appendChild(bodyEl); + contentEl.appendChild(closeButtonEl); + toastEl.appendChild(contentEl); + container.appendChild(toastEl); + + const bsToast = new bootstrap.Toast(toastEl, { delay: 5000 }); + bsToast.show(); + + toastEl.addEventListener('hidden.bs.toast', () => { + toastEl.remove(); + }); +} + +function getGovernanceFeatureToggle(featureKey) { + const toggle = document.getElementById(featureKey); + return toggle instanceof HTMLInputElement ? toggle : null; +} + +function isGovernanceFeatureApplicable(featureKey) { + if (featureKey === 'governance_global_endpoints') { + return true; + } + + const primaryToggleId = GOVERNANCE_PRIMARY_TOGGLE_MAP[featureKey]; + if (!primaryToggleId) { + return true; + } + + const primaryToggle = document.getElementById(primaryToggleId); + if (!(primaryToggle instanceof HTMLInputElement)) { + return true; + } + + return primaryToggle.checked; +} + +function syncGovernanceFeatureToggleVisibility() { + Object.keys(GOVERNANCE_FEATURE_LABELS).forEach((featureKey) => { + const featureToggle = getGovernanceFeatureToggle(featureKey); + const wrapper = featureToggle?.closest('.form-check'); + if (!wrapper) { + return; + } + wrapper.classList.toggle('d-none', !isGovernanceFeatureApplicable(featureKey)); + }); +} + +function syncGovernanceFeaturePolicyRowVisibility(row) { + const featureKey = String(row?.dataset?.featureKey || '').trim(); + if (!row || !featureKey) { + return; + } + + const featureToggle = getGovernanceFeatureToggle(featureKey); + const shouldShow = isGovernanceFeatureApplicable(featureKey) && (!featureToggle || featureToggle.checked); + row.classList.toggle('d-none', !shouldShow); +} + +function syncGovernanceFeaturePolicyVisibility() { + syncGovernanceFeatureToggleVisibility(); + Array.from(document.querySelectorAll('#governance-feature-policies-body tr')).forEach((row) => { + syncGovernanceFeaturePolicyRowVisibility(row); + }); +} + +function buildFeaturePolicyRow(policy) { + const row = document.createElement('tr'); + row.dataset.featureKey = policy.feature_key; + + const featureCell = document.createElement('td'); + featureCell.textContent = GOVERNANCE_FEATURE_LABELS[policy.feature_key] || policy.feature_key; + + const allowAllCell = document.createElement('td'); + const allowAll = document.createElement('input'); + allowAll.type = 'checkbox'; + allowAll.className = 'form-check-input governance-allow-all'; + allowAll.checked = Boolean(policy.allow_all); + allowAllCell.appendChild(allowAll); + + const usersCell = document.createElement('td'); + const usersInput = document.createElement('input'); + usersInput.type = 'text'; + usersInput.className = 'form-control form-control-sm governance-allowed-users d-none'; + usersInput.value = joinPrincipalList(policy.allowed_users); + usersCell.appendChild(usersInput); + + const usersSummary = document.createElement('div'); + usersSummary.className = 'small text-body-secondary governance-allowlist-summary'; + usersSummary.textContent = buildAllowListSummary(policy.allowed_users, policy.allowed_groups); + usersCell.appendChild(usersSummary); + + const usersEditButton = document.createElement('button'); + usersEditButton.type = 'button'; + usersEditButton.className = 'btn btn-sm btn-outline-primary mt-1 governance-edit-feature-allowlist-btn'; + usersEditButton.textContent = 'Edit Allow List'; + usersCell.appendChild(usersEditButton); + + const groupsCell = document.createElement('td'); + const groupsInput = document.createElement('input'); + groupsInput.type = 'text'; + groupsInput.className = 'form-control form-control-sm governance-allowed-groups d-none'; + groupsInput.value = joinPrincipalList(policy.allowed_groups); + groupsCell.appendChild(groupsInput); + + const groupsSummary = document.createElement('div'); + groupsSummary.className = 'small text-body-secondary'; + groupsSummary.textContent = 'Includes group IDs added in the editor.'; + groupsCell.appendChild(groupsSummary); + + row.appendChild(featureCell); + row.appendChild(allowAllCell); + row.appendChild(usersCell); + row.appendChild(groupsCell); + + applyFeatureAllowAllUiState(row); + syncGovernanceFeaturePolicyRowVisibility(row); + + return row; +} + +async function loadFeaturePolicies() { + const tbody = document.getElementById('governance-feature-policies-body'); + if (!tbody) { + return; + } + + const response = await fetch('/api/admin/governance/policies', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to load governance feature policies.'); + } + + const payload = await response.json(); + const featurePolicies = Array.isArray(payload.features) ? payload.features : []; + + tbody.innerHTML = ''; + featurePolicies.forEach((policy) => { + tbody.appendChild(buildFeaturePolicyRow(policy)); + }); + syncGovernanceFeaturePolicyVisibility(); +} + +async function saveFeaturePolicies() { + const rows = Array.from(document.querySelectorAll('#governance-feature-policies-body tr')); + if (rows.length === 0) { + setGovernanceStatus('No feature policies are available to save.', 'warning'); + return; + } + + for (const row of rows) { + const featureKey = row.dataset.featureKey; + const allowAllInput = row.querySelector('.governance-allow-all'); + const usersInput = row.querySelector('.governance-allowed-users'); + const groupsInput = row.querySelector('.governance-allowed-groups'); + + if (!featureKey || !allowAllInput || !usersInput || !groupsInput) { + continue; + } + + const body = { + allow_all: allowAllInput.checked, + allowed_users: splitPrincipalList(usersInput.value), + allowed_groups: splitPrincipalList(groupsInput.value), + }; + + const response = await fetch(`/api/admin/governance/policies/${encodeURIComponent(featureKey)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(`Failed to save policy for ${featureKey}.`); + } + } + + clearGovernanceStatus(); + showGovernanceToast('Governance feature policies saved successfully.', 'success'); +} + +function buildItemPolicyRow(policy) { + const row = document.createElement('tr'); + + const policyCell = document.createElement('td'); + const entityTypeCell = document.createElement('td'); + const entityType = normalizeGovernanceItemEntityType(policy.entity_type); + const itemId = String(policy.item_id || ''); + const policyId = String(policy.policy_id || ''); + const resourceLabel = String(policy.resource_label || '').trim(); + const policyName = String(policy.policy_name || '').trim() || buildDefaultItemPolicyName(entityType, itemId, resourceLabel); + const allowAll = Boolean(policy.allow_all); + const allowedUsers = Array.isArray(policy.allowed_users) ? policy.allowed_users : []; + const allowedGroups = Array.isArray(policy.allowed_groups) ? policy.allowed_groups : []; + + const policyNameEl = document.createElement('div'); + policyNameEl.className = 'fw-semibold'; + policyNameEl.textContent = policyName; + policyCell.appendChild(policyNameEl); + + policyCell.title = policyId ? `Policy ID: ${policyId}` : policyName; + + entityTypeCell.textContent = buildItemPolicyEntityLabel(entityType); + + const itemIdCell = document.createElement('td'); + const itemLabelEl = document.createElement('div'); + itemLabelEl.textContent = resourceLabel || itemId; + itemLabelEl.title = itemId; + itemIdCell.appendChild(itemLabelEl); + + const allowAllCell = document.createElement('td'); + allowAllCell.textContent = allowAll ? 'Yes' : 'No'; + + const usersCell = document.createElement('td'); + renderGovernancePrincipalReviewCell(usersCell, 'users', allowedUsers); + + const groupsCell = document.createElement('td'); + renderGovernancePrincipalReviewCell(groupsCell, 'groups', allowedGroups); + + const actionsCell = document.createElement('td'); + actionsCell.className = 'text-nowrap'; + const editButton = document.createElement('button'); + editButton.type = 'button'; + editButton.className = 'btn btn-sm btn-outline-primary governance-edit-item-policy-btn'; + editButton.textContent = 'Edit'; + editButton.dataset.entityType = entityType; + editButton.dataset.itemId = itemId; + editButton.dataset.policyId = policyId; + editButton.dataset.policyName = policyName; + editButton.dataset.resourceLabel = resourceLabel; + editButton.dataset.allowAll = allowAll ? 'true' : 'false'; + editButton.dataset.allowedUsers = JSON.stringify(allowedUsers); + editButton.dataset.allowedGroups = JSON.stringify(allowedGroups); + actionsCell.appendChild(editButton); + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'btn btn-sm btn-outline-danger ms-2 governance-delete-item-policy-btn'; + deleteButton.textContent = 'Delete'; + deleteButton.dataset.entityType = entityType; + deleteButton.dataset.itemId = itemId; + deleteButton.dataset.policyId = policyId; + deleteButton.dataset.policyName = policyName; + actionsCell.appendChild(deleteButton); + + row.appendChild(policyCell); + row.appendChild(entityTypeCell); + row.appendChild(itemIdCell); + row.appendChild(allowAllCell); + row.appendChild(usersCell); + row.appendChild(groupsCell); + row.appendChild(actionsCell); + + return row; +} + +function renderGovernancePrincipalReviewCell(cell, listType, ids, hydrateMissing = true) { + if (!cell) { + return; + } + + const normalizedIds = uniquePrincipalList(ids); + cell.textContent = ''; + cell.className = 'small'; + + if (normalizedIds.length === 0) { + cell.className = 'small text-muted'; + cell.textContent = 'None'; + return; + } + + const missingIds = []; + normalizedIds.forEach((idValue) => { + const displayName = getGovernanceDisplayName(listType, idValue); + const truncatedId = truncateGovernanceId(idValue); + const wrapper = document.createElement('div'); + wrapper.className = 'mb-1'; + + const primary = document.createElement('div'); + primary.textContent = displayName || truncatedId; + primary.title = displayName || idValue; + wrapper.appendChild(primary); + + if (displayName) { + const secondary = document.createElement('div'); + secondary.className = 'text-muted'; + secondary.textContent = truncatedId; + secondary.title = idValue; + wrapper.appendChild(secondary); + } else { + missingIds.push(idValue); + } + + cell.appendChild(wrapper); + }); + + if (hydrateMissing && missingIds.length > 0) { + void hydrateGovernanceDisplayNames(listType, missingIds).then(() => { + renderGovernancePrincipalReviewCell(cell, listType, normalizedIds, false); + }).catch(() => { + renderGovernancePrincipalReviewCell(cell, listType, normalizedIds, false); + }); + } +} + +function parseGovernancePrincipalDataset(value) { + try { + const parsed = JSON.parse(value || '[]'); + return uniquePrincipalList(Array.isArray(parsed) ? parsed : []); + } catch (error) { + return []; + } +} + +function ensureGovernanceItemIdOption(itemIdInput, itemId, label = '') { + const normalizedItemId = String(itemId || '').trim(); + if (!itemIdInput || !normalizedItemId) { + return; + } + + const hasOption = Array.from(itemIdInput.options || []).some((option) => option.value === normalizedItemId); + if (!hasOption) { + const option = document.createElement('option'); + option.value = normalizedItemId; + option.textContent = label ? `${label} (${truncateGovernanceId(normalizedItemId)})` : normalizedItemId; + itemIdInput.appendChild(option); + } + itemIdInput.value = normalizedItemId; +} + +function resetGovernanceItemEditorSelectionViewState() { + governanceItemEditorSelectionViewState.users.search = ''; + governanceItemEditorSelectionViewState.users.page = 1; + governanceItemEditorSelectionViewState.users.pageSize = GOVERNANCE_ITEM_EDITOR_PAGE_SIZE_DEFAULT; + + governanceItemEditorSelectionViewState.groups.search = ''; + governanceItemEditorSelectionViewState.groups.page = 1; + governanceItemEditorSelectionViewState.groups.pageSize = GOVERNANCE_ITEM_EDITOR_PAGE_SIZE_DEFAULT; +} + +function syncGovernanceItemEditorSelectionControls() { + const userSearchInput = document.getElementById('governance-item-selected-user-search'); + const userPageSizeSelect = document.getElementById('governance-item-selected-user-page-size'); + const groupSearchInput = document.getElementById('governance-item-selected-group-search'); + const groupPageSizeSelect = document.getElementById('governance-item-selected-group-page-size'); + + if (userSearchInput) { + userSearchInput.value = governanceItemEditorSelectionViewState.users.search; + } + if (userPageSizeSelect) { + userPageSizeSelect.value = String(governanceItemEditorSelectionViewState.users.pageSize); + } + if (groupSearchInput) { + groupSearchInput.value = governanceItemEditorSelectionViewState.groups.search; + } + if (groupPageSizeSelect) { + groupPageSizeSelect.value = String(governanceItemEditorSelectionViewState.groups.pageSize); + } +} + +function getGovernanceItemEditorSelectedIds(listType) { + const input = listType === 'groups' ? getItemGroupsInput() : getItemUsersInput(); + return splitPrincipalList(input?.value || ''); +} + +function setGovernanceItemEditorSelectedIds(listType, values) { + const input = listType === 'groups' ? getItemGroupsInput() : getItemUsersInput(); + if (!input) { + return; + } + input.value = joinPrincipalList(uniquePrincipalList(values)); + updateItemAllowListSummary(); + renderGovernanceItemEditorSelections(); +} + +function getFilteredGovernanceItemEditorSelectedIds(listType) { + const allIds = getGovernanceItemEditorSelectedIds(listType); + const state = governanceItemEditorSelectionViewState[listType]; + const searchValue = String(state?.search || '').trim().toLowerCase(); + if (!searchValue) { + return allIds; + } + return allIds.filter((value) => { + const idText = String(value || '').toLowerCase(); + const displayName = String(getGovernanceDisplayName(listType, value) || '').toLowerCase(); + return idText.includes(searchValue) || displayName.includes(searchValue); + }); +} + +function setGovernanceItemEditorStatus(message, level = 'muted') { + const status = document.getElementById('governance-item-editor-status'); + if (!status) { + return; + } + + status.classList.remove('text-muted', 'text-success', 'text-warning', 'text-danger'); + const className = { + muted: 'text-muted', + success: 'text-success', + warning: 'text-warning', + danger: 'text-danger', + }[level] || 'text-muted'; + status.classList.add(className); + status.textContent = String(message || ''); +} + +function renderGovernanceItemEditorSelectedList(options) { + const { + listType, + containerId, + checkboxClass, + emptyMessage, + summaryId, + prevButtonId, + nextButtonId, + } = options; + + const tbody = document.getElementById(containerId); + const summary = document.getElementById(summaryId); + const prevButton = document.getElementById(prevButtonId); + const nextButton = document.getElementById(nextButtonId); + const state = governanceItemEditorSelectionViewState[listType]; + + if (!tbody || !state) { + return; + } + + const filteredIds = getFilteredGovernanceItemEditorSelectedIds(listType); + const pageSize = normalizeGovernanceAllowListPageSize(state.pageSize); + state.pageSize = pageSize; + const totalItems = filteredIds.length; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + state.page = Math.min(Math.max(1, state.page), totalPages); + const startIndex = (state.page - 1) * pageSize; + const visibleIds = filteredIds.slice(startIndex, startIndex + pageSize); + + tbody.innerHTML = ''; + if (visibleIds.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 3; + cell.className = 'text-center text-muted'; + cell.textContent = emptyMessage; + row.appendChild(cell); + tbody.appendChild(row); + } else { + visibleIds.forEach((idValue) => { + const row = document.createElement('tr'); + + const checkCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = checkboxClass; + checkbox.value = idValue; + checkCell.appendChild(checkbox); + + const displayName = getGovernanceDisplayName(listType, idValue); + const truncatedId = truncateGovernanceId(idValue); + const displayText = displayName ? `${displayName} (${truncatedId})` : truncatedId; + + const infoCell = document.createElement('td'); + infoCell.className = 'small'; + infoCell.textContent = displayText; + infoCell.title = idValue; + + const copyCell = document.createElement('td'); + copyCell.className = 'text-center'; + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.className = 'btn btn-sm btn-link p-0'; + setBootstrapIcon(copyButton, 'bi bi-clipboard'); + copyButton.title = 'Copy ID'; + copyButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + navigator.clipboard.writeText(idValue).then(() => { + setBootstrapIcon(copyButton, 'bi bi-check'); + setTimeout(() => { + setBootstrapIcon(copyButton, 'bi bi-clipboard'); + }, 1500); + }).catch(() => { + setBootstrapIcon(copyButton, 'bi bi-x'); + setTimeout(() => { + setBootstrapIcon(copyButton, 'bi bi-clipboard'); + }, 1500); + }); + }); + copyCell.appendChild(copyButton); + + row.appendChild(checkCell); + row.appendChild(infoCell); + row.appendChild(copyCell); + tbody.appendChild(row); + }); + } + + if (summary) { + const viewStart = totalItems === 0 ? 0 : startIndex + 1; + const viewEnd = totalItems === 0 ? 0 : Math.min(startIndex + visibleIds.length, totalItems); + summary.textContent = `Showing ${viewStart}-${viewEnd} of ${totalItems} selected (${state.page}/${totalPages}).`; + } + + if (prevButton) { + prevButton.disabled = state.page <= 1 || totalItems === 0; + } + if (nextButton) { + nextButton.disabled = state.page >= totalPages || totalItems === 0; + } +} + +function renderGovernanceItemEditorSelections() { + const users = getGovernanceItemEditorSelectedIds('users'); + const groups = getGovernanceItemEditorSelectedIds('groups'); + + void hydrateGovernanceDisplayNames('users', users); + void hydrateGovernanceDisplayNames('groups', groups); + + renderGovernanceItemEditorSelectedList({ + listType: 'users', + containerId: 'governance-item-selected-users', + checkboxClass: 'governance-item-selected-user-checkbox', + emptyMessage: 'No users selected.', + summaryId: 'governance-item-selected-users-summary', + prevButtonId: 'governance-item-selected-users-prev-btn', + nextButtonId: 'governance-item-selected-users-next-btn', + }); + renderGovernanceItemEditorSelectedList({ + listType: 'groups', + containerId: 'governance-item-selected-groups', + checkboxClass: 'governance-item-selected-group-checkbox', + emptyMessage: 'No groups selected.', + summaryId: 'governance-item-selected-groups-summary', + prevButtonId: 'governance-item-selected-groups-prev-btn', + nextButtonId: 'governance-item-selected-groups-next-btn', + }); +} + +async function openGovernanceItemPolicyEditor(policy = null) { + const modalElement = ensureGovernanceItemPolicyEditorModal(); + if (!modalElement) { + return; + } + + const title = document.getElementById('governance-item-policy-editor-title'); + const policyIdInput = getItemPolicyIdInput(); + const policyNameInput = getItemPolicyNameInput(); + const resourceLabelInput = getItemResourceLabelInput(); + const entityTypeInput = getItemEntityTypeInput(); + const itemIdInput = getItemIdInput(); + const itemFilterInput = getItemLookupFilterInput(); + const allowAllInput = getItemAllowAllInput(); + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + + if (!entityTypeInput || !itemIdInput || !allowAllInput || !usersInput || !groupsInput) { + return; + } + + const entityType = normalizeGovernanceItemEntityType(policy?.entity_type) || 'global_agent'; + const itemId = String(policy?.item_id || '').trim(); + const resourceLabel = String(policy?.resource_label || '').trim(); + const policyName = String(policy?.policy_name || '').trim() || buildDefaultItemPolicyName(entityType, itemId, resourceLabel); + + if (title) { + title.textContent = policy?.policy_id ? 'Edit Delegated Item Policy' : 'New Delegated Item Policy'; + } + if (itemFilterInput) { + itemFilterInput.value = ''; + } + + entityTypeInput.value = entityType; + if (policyIdInput) { + policyIdInput.value = String(policy?.policy_id || '').trim(); + } + if (policyNameInput) { + policyNameInput.value = policyName; + } + if (resourceLabelInput) { + resourceLabelInput.value = resourceLabel; + } + resetGovernanceItemEditorSelectionViewState(); + syncGovernanceItemEditorSelectionControls(); + renderGovernanceItemEditorUserResults([]); + renderGovernanceItemEditorGroupResults([]); + setGovernanceItemEditorStatus(''); + + await refreshGovernanceItemLookup(entityType, false, itemId); + ensureGovernanceItemIdOption(itemIdInput, itemId, resourceLabel); + + allowAllInput.checked = policy ? Boolean(policy.allow_all) : true; + usersInput.value = joinPrincipalList(policy?.allowed_users || []); + groupsInput.value = joinPrincipalList(policy?.allowed_groups || []); + + applyItemAllowAllUiState(); + governanceItemPolicyEditorModal?.show(); + itemIdInput.focus(); +} + +async function openGovernanceDelegatedItemEditorFromResource(options = {}) { + const entityType = normalizeGovernanceItemEntityType(options.entityType || options.entity_type || ''); + const itemId = String(options.itemId || options.item_id || '').trim(); + const resourceLabel = String(options.resourceLabel || options.resource_label || '').trim(); + const policyName = String(options.policyName || options.policy_name || '').trim() || buildDefaultItemPolicyName(entityType, itemId, resourceLabel); + + if (!entityType || !itemId) { + setGovernanceStatus('Unable to open delegated item policy editor without a resource ID.', 'warning'); + return; + } + + if (typeof window.openAdminSettingsTab === 'function') { + window.openAdminSettingsTab('#governance'); + } + + await openGovernanceItemPolicyEditor({ + entity_type: entityType, + item_id: itemId, + policy_name: policyName, + resource_label: resourceLabel, + allow_all: true, + allowed_users: [], + allowed_groups: [], + }); +} + +window.openGovernanceDelegatedItemEditor = openGovernanceDelegatedItemEditorFromResource; + +function ensureGovernanceItemPolicyEditorModal() { + let modalElement = document.getElementById('governance-item-policy-editor-modal'); + if (!modalElement) { + const modalMarkup = ` + + `; + + const wrapper = document.createElement('div'); + // xss-check: ignore - modalMarkup is a static Bootstrap shell; untrusted values are populated with DOM APIs. + wrapper.innerHTML = modalMarkup.trim(); + modalElement = wrapper.firstElementChild; + document.body.appendChild(modalElement); + } + + if (!governanceItemPolicyEditorModal) { + governanceItemPolicyEditorModal = bootstrap.Modal.getOrCreateInstance(modalElement); + } + + if (!modalElement.dataset.wired) { + modalElement.dataset.wired = 'true'; + wireGovernanceItemPolicyEditorHandlers(modalElement); + } + + return modalElement; +} + +function renderGovernanceItemEditorUserResults(users) { + const tbody = document.getElementById('governance-item-user-results'); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(users) || users.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 3; + cell.className = 'text-center text-muted'; + cell.textContent = 'No users found.'; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + users.forEach((user) => { + const userId = String(user.id || '').trim(); + const upn = String(user.userPrincipalName || user.mail || user.email || '').trim(); + const displayName = String(user.displayName || upn || '(no name)').trim(); + const userLabel = buildGovernanceUserLabel(user); + + if (userId && userLabel) { + setGovernanceDisplayName('users', userId, userLabel); + } + + const row = document.createElement('tr'); + + const selectCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'governance-item-user-result-checkbox'; + checkbox.value = userId; + checkbox.dataset.displayLabel = userLabel; + selectCell.appendChild(checkbox); + + const userCell = document.createElement('td'); + userCell.textContent = displayName; + + const emailCell = document.createElement('td'); + emailCell.textContent = upn; + + row.appendChild(selectCell); + row.appendChild(userCell); + row.appendChild(emailCell); + tbody.appendChild(row); + }); +} + +function renderGovernanceItemEditorGroupResults(groups) { + const tbody = document.getElementById('governance-item-group-results'); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(groups) || groups.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 3; + cell.className = 'text-center text-muted'; + cell.textContent = 'No groups found.'; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + groups.forEach((group) => { + const groupId = String(group.id || '').trim(); + const groupName = String(group.name || 'Unnamed Group'); + + if (groupId && groupName) { + setGovernanceDisplayName('groups', groupId, groupName); + } + + const row = document.createElement('tr'); + + const selectCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'governance-item-group-result-checkbox'; + checkbox.value = groupId; + checkbox.dataset.displayLabel = groupName; + selectCell.appendChild(checkbox); + + const nameCell = document.createElement('td'); + nameCell.textContent = groupName; + + const idCell = document.createElement('td'); + idCell.textContent = groupId; + + row.appendChild(selectCell); + row.appendChild(nameCell); + row.appendChild(idCell); + tbody.appendChild(row); + }); +} + +async function loadGovernanceItemEditorUserResults() { + const searchInput = document.getElementById('governance-item-user-search'); + const query = String(searchInput?.value || '').trim(); + if (!query) { + setGovernanceItemEditorStatus('Enter a user search term.', 'warning'); + renderGovernanceItemEditorUserResults([]); + return; + } + + const users = await governanceLookupUsers(query); + renderGovernanceItemEditorUserResults(users); + renderGovernanceItemEditorSelections(); + setGovernanceItemEditorStatus('User results updated.', 'success'); +} + +async function loadGovernanceItemEditorGroupResults() { + const searchInput = document.getElementById('governance-item-group-search'); + const query = String(searchInput?.value || '').trim(); + const groups = await governanceLookupGroups(query); + renderGovernanceItemEditorGroupResults(groups); + renderGovernanceItemEditorSelections(); + setGovernanceItemEditorStatus('Group results updated.', 'success'); +} + +function wireGovernanceItemPolicyEditorHandlers(modalElement) { + const saveButton = modalElement.querySelector('#governance-save-item-policy-btn'); + if (saveButton) { + saveButton.addEventListener('click', async (event) => { + clearGovernanceStatus(); + try { + await saveItemPolicy(event); + } catch (error) { + setGovernanceItemEditorStatus(error.message || 'Failed to save item policy.', 'danger'); + setGovernanceStatus(error.message || 'Failed to save item policy.', 'danger'); + } + }); + } + + const entityTypeInput = modalElement.querySelector('#governance-item-entity-type'); + if (entityTypeInput) { + entityTypeInput.addEventListener('change', async () => { + const entityType = String(entityTypeInput.value || '').trim(); + const filterInput = getItemLookupFilterInput(); + if (filterInput) { + filterInput.value = ''; + } + await refreshGovernanceItemLookup(entityType, false, ''); + }); + } + + const itemLookupRefreshButton = modalElement.querySelector('#governance-item-id-refresh-btn'); + if (itemLookupRefreshButton) { + itemLookupRefreshButton.addEventListener('click', async () => { + const entityType = String(getItemEntityTypeInput()?.value || '').trim(); + await refreshGovernanceItemLookup(entityType, true, String(getItemIdInput()?.value || '').trim()); + }); + } + + const itemLookupFilterInput = modalElement.querySelector('#governance-item-id-filter'); + if (itemLookupFilterInput) { + itemLookupFilterInput.addEventListener('input', () => { + const entityType = String(getItemEntityTypeInput()?.value || '').trim(); + renderGovernanceItemLookupOptions(entityType, String(getItemIdInput()?.value || '').trim()); + }); + } + + const allowAllInput = modalElement.querySelector('#governance-item-allow-all'); + if (allowAllInput) { + allowAllInput.addEventListener('change', () => { + applyItemAllowAllUiState(); + }); + } + + const userSearchButton = modalElement.querySelector('#governance-item-user-search-btn'); + if (userSearchButton) { + userSearchButton.addEventListener('click', async () => { + try { + await loadGovernanceItemEditorUserResults(); + } catch (error) { + setGovernanceItemEditorStatus(error.message || 'Failed to load user results.', 'danger'); + } + }); + } + + const groupSearchButton = modalElement.querySelector('#governance-item-group-search-btn'); + if (groupSearchButton) { + groupSearchButton.addEventListener('click', async () => { + try { + await loadGovernanceItemEditorGroupResults(); + } catch (error) { + setGovernanceItemEditorStatus(error.message || 'Failed to load group results.', 'danger'); + } + }); + } + + const addSelectedUsersButton = modalElement.querySelector('#governance-item-add-selected-users-btn'); + if (addSelectedUsersButton) { + addSelectedUsersButton.addEventListener('click', () => { + const selectedUserIds = readCheckedValues('.governance-item-user-result-checkbox'); + selectedUserIds.forEach((userId) => { + const userCheckbox = Array.from(document.querySelectorAll('.governance-item-user-result-checkbox')).find( + (checkbox) => checkbox.value === userId + ); + const displayLabel = String(userCheckbox?.dataset.displayLabel || '').trim(); + if (displayLabel) { + setGovernanceDisplayName('users', userId, displayLabel); + } + }); + setGovernanceItemEditorSelectedIds('users', [...getGovernanceItemEditorSelectedIds('users'), ...selectedUserIds]); + setGovernanceItemEditorStatus(`Added ${selectedUserIds.length} user${selectedUserIds.length === 1 ? '' : 's'}.`, 'success'); + }); + } + + const addSelectedGroupsButton = modalElement.querySelector('#governance-item-add-selected-groups-btn'); + if (addSelectedGroupsButton) { + addSelectedGroupsButton.addEventListener('click', () => { + const selectedGroupIds = readCheckedValues('.governance-item-group-result-checkbox'); + selectedGroupIds.forEach((groupId) => { + const groupCheckbox = Array.from(document.querySelectorAll('.governance-item-group-result-checkbox')).find( + (checkbox) => checkbox.value === groupId + ); + const displayLabel = String(groupCheckbox?.dataset.displayLabel || '').trim(); + if (displayLabel) { + setGovernanceDisplayName('groups', groupId, displayLabel); + } + }); + setGovernanceItemEditorSelectedIds('groups', [...getGovernanceItemEditorSelectedIds('groups'), ...selectedGroupIds]); + setGovernanceItemEditorStatus(`Added ${selectedGroupIds.length} group${selectedGroupIds.length === 1 ? '' : 's'}.`, 'success'); + }); + } + + const removeSelectedUsersButton = modalElement.querySelector('#governance-item-remove-selected-users-btn'); + if (removeSelectedUsersButton) { + removeSelectedUsersButton.addEventListener('click', () => { + const selectedUserIds = readCheckedValues('.governance-item-selected-user-checkbox'); + setGovernanceItemEditorSelectedIds('users', removeCheckedFromList(getGovernanceItemEditorSelectedIds('users'), selectedUserIds)); + setGovernanceItemEditorStatus(`Removed ${selectedUserIds.length} user${selectedUserIds.length === 1 ? '' : 's'}.`, 'success'); + }); + } + + const removeSelectedGroupsButton = modalElement.querySelector('#governance-item-remove-selected-groups-btn'); + if (removeSelectedGroupsButton) { + removeSelectedGroupsButton.addEventListener('click', () => { + const selectedGroupIds = readCheckedValues('.governance-item-selected-group-checkbox'); + setGovernanceItemEditorSelectedIds('groups', removeCheckedFromList(getGovernanceItemEditorSelectedIds('groups'), selectedGroupIds)); + setGovernanceItemEditorStatus(`Removed ${selectedGroupIds.length} group${selectedGroupIds.length === 1 ? '' : 's'}.`, 'success'); + }); + } + + const clearUsersButton = modalElement.querySelector('#governance-item-clear-users-btn'); + if (clearUsersButton) { + clearUsersButton.addEventListener('click', () => { + setGovernanceItemEditorSelectedIds('users', []); + setGovernanceItemEditorStatus('Cleared selected users.', 'success'); + }); + } + + const clearGroupsButton = modalElement.querySelector('#governance-item-clear-groups-btn'); + if (clearGroupsButton) { + clearGroupsButton.addEventListener('click', () => { + setGovernanceItemEditorSelectedIds('groups', []); + setGovernanceItemEditorStatus('Cleared selected groups.', 'success'); + }); + } + + const csvApplyButton = modalElement.querySelector('#governance-item-csv-apply-btn'); + if (csvApplyButton) { + csvApplyButton.addEventListener('click', () => { + const targetSelect = document.getElementById('governance-item-csv-target'); + const modeSelect = document.getElementById('governance-item-csv-mode'); + const csvInput = document.getElementById('governance-item-csv-input'); + const target = String(targetSelect?.value || 'users'); + const mode = String(modeSelect?.value || 'merge'); + const importedValues = uniquePrincipalList(parseCsvPrincipalLines(csvInput?.value || '')); + + if (importedValues.length === 0) { + setGovernanceItemEditorStatus('No CSV values detected.', 'warning'); + return; + } + + const existingValues = getGovernanceItemEditorSelectedIds(target); + const nextValues = mode === 'replace' ? importedValues : uniquePrincipalList([...existingValues, ...importedValues]); + setGovernanceItemEditorSelectedIds(target, nextValues); + setGovernanceItemEditorStatus(`CSV ${mode} completed for ${target}. Imported ${importedValues.length} ID${importedValues.length === 1 ? '' : 's'}.`, 'success'); + }); + } + + const selectedUserSearchInput = modalElement.querySelector('#governance-item-selected-user-search'); + if (selectedUserSearchInput) { + selectedUserSearchInput.addEventListener('input', () => { + governanceItemEditorSelectionViewState.users.search = String(selectedUserSearchInput.value || '').trim(); + governanceItemEditorSelectionViewState.users.page = 1; + renderGovernanceItemEditorSelections(); + }); + } + + const selectedGroupSearchInput = modalElement.querySelector('#governance-item-selected-group-search'); + if (selectedGroupSearchInput) { + selectedGroupSearchInput.addEventListener('input', () => { + governanceItemEditorSelectionViewState.groups.search = String(selectedGroupSearchInput.value || '').trim(); + governanceItemEditorSelectionViewState.groups.page = 1; + renderGovernanceItemEditorSelections(); + }); + } + + const selectedUserPageSizeSelect = modalElement.querySelector('#governance-item-selected-user-page-size'); + if (selectedUserPageSizeSelect) { + selectedUserPageSizeSelect.addEventListener('change', () => { + governanceItemEditorSelectionViewState.users.pageSize = normalizeGovernanceAllowListPageSize(selectedUserPageSizeSelect.value); + governanceItemEditorSelectionViewState.users.page = 1; + renderGovernanceItemEditorSelections(); + }); + } + + const selectedGroupPageSizeSelect = modalElement.querySelector('#governance-item-selected-group-page-size'); + if (selectedGroupPageSizeSelect) { + selectedGroupPageSizeSelect.addEventListener('change', () => { + governanceItemEditorSelectionViewState.groups.pageSize = normalizeGovernanceAllowListPageSize(selectedGroupPageSizeSelect.value); + governanceItemEditorSelectionViewState.groups.page = 1; + renderGovernanceItemEditorSelections(); + }); + } + + const selectedUsersPrevButton = modalElement.querySelector('#governance-item-selected-users-prev-btn'); + if (selectedUsersPrevButton) { + selectedUsersPrevButton.addEventListener('click', () => { + governanceItemEditorSelectionViewState.users.page = Math.max(1, governanceItemEditorSelectionViewState.users.page - 1); + renderGovernanceItemEditorSelections(); + }); + } + + const selectedUsersNextButton = modalElement.querySelector('#governance-item-selected-users-next-btn'); + if (selectedUsersNextButton) { + selectedUsersNextButton.addEventListener('click', () => { + governanceItemEditorSelectionViewState.users.page += 1; + renderGovernanceItemEditorSelections(); + }); + } + + const selectedGroupsPrevButton = modalElement.querySelector('#governance-item-selected-groups-prev-btn'); + if (selectedGroupsPrevButton) { + selectedGroupsPrevButton.addEventListener('click', () => { + governanceItemEditorSelectionViewState.groups.page = Math.max(1, governanceItemEditorSelectionViewState.groups.page - 1); + renderGovernanceItemEditorSelections(); + }); + } + + const selectedGroupsNextButton = modalElement.querySelector('#governance-item-selected-groups-next-btn'); + if (selectedGroupsNextButton) { + selectedGroupsNextButton.addEventListener('click', () => { + governanceItemEditorSelectionViewState.groups.page += 1; + renderGovernanceItemEditorSelections(); + }); + } + + const selectAllMappings = [ + ['governance-item-select-all-user-results', '.governance-item-user-result-checkbox'], + ['governance-item-select-all-group-results', '.governance-item-group-result-checkbox'], + ['governance-item-select-all-selected-users', '.governance-item-selected-user-checkbox'], + ['governance-item-select-all-selected-groups', '.governance-item-selected-group-checkbox'], + ]; + + selectAllMappings.forEach(([masterId, checkboxSelector]) => { + const master = modalElement.querySelector(`#${masterId}`); + if (!master) { + return; + } + master.addEventListener('change', () => { + toggleCheckboxes(checkboxSelector, master.checked); + }); + }); +} + +function ensureGovernanceItemPolicyDeleteModal() { + let modalElement = document.getElementById('governance-item-policy-delete-confirm-modal'); + if (!modalElement) { + const modalMarkup = ` + + `; + + const wrapper = document.createElement('div'); + // xss-check: ignore - modalMarkup is a static Bootstrap shell; untrusted values are populated with DOM APIs. + wrapper.innerHTML = modalMarkup.trim(); + modalElement = wrapper.firstElementChild; + document.body.appendChild(modalElement); + } + + if (!governanceItemPolicyDeleteModal) { + governanceItemPolicyDeleteModal = bootstrap.Modal.getOrCreateInstance(modalElement); + } + + if (!modalElement.dataset.wired) { + modalElement.dataset.wired = 'true'; + const confirmButton = document.getElementById('governance-item-policy-delete-confirm-btn'); + if (confirmButton) { + confirmButton.addEventListener('click', async () => { + try { + await deleteGovernanceItemPolicyFromContext(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to delete item governance policy.', 'danger'); + } + }); + } + } + + return modalElement; +} + +function openGovernanceItemPolicyDeleteModal(entityType, itemId, policyId = '', policyName = '') { + const normalizedEntityType = normalizeGovernanceItemEntityType(entityType); + const normalizedItemId = String(itemId || '').trim(); + const normalizedPolicyId = String(policyId || '').trim(); + const normalizedPolicyName = String(policyName || '').trim(); + if (!normalizedEntityType || !normalizedItemId) { + return; + } + + governanceItemPolicyDeleteContext = { + entityType: normalizedEntityType, + itemId: normalizedItemId, + policyId: normalizedPolicyId, + }; + + ensureGovernanceItemPolicyDeleteModal(); + const summary = document.getElementById('governance-item-policy-delete-summary'); + if (summary) { + const policyPrefix = normalizedPolicyName ? `${normalizedPolicyName} - ` : ''; + summary.textContent = `${policyPrefix}${buildItemPolicyEntityLabel(normalizedEntityType)}: ${normalizedItemId}`; + } + governanceItemPolicyDeleteModal?.show(); +} + +async function deleteGovernanceItemPolicyFromContext() { + if (!governanceItemPolicyDeleteContext) { + return; + } + + const { entityType, itemId, policyId } = governanceItemPolicyDeleteContext; + const deleteUrl = policyId + ? `/api/admin/governance/item-policies/${encodeURIComponent(entityType)}/${encodeURIComponent(itemId)}/${encodeURIComponent(policyId)}` + : `/api/admin/governance/item-policies/${encodeURIComponent(entityType)}/${encodeURIComponent(itemId)}`; + const response = await fetch( + deleteUrl, + { + method: 'DELETE', + headers: { + Accept: 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error('Unable to delete item governance policy.'); + } + + governanceItemPolicyDeleteModal?.hide(); + governanceItemPolicyDeleteContext = null; + + const entityTypeInput = document.getElementById('governance-item-entity-type'); + const itemIdInput = document.getElementById('governance-item-id'); + const policyIdInput = getItemPolicyIdInput(); + if (entityTypeInput?.value === entityType && itemIdInput?.value === itemId && (!policyId || policyIdInput?.value === policyId)) { + const allowAllInput = getItemAllowAllInput(); + const usersInput = getItemUsersInput(); + const groupsInput = getItemGroupsInput(); + const policyNameInput = getItemPolicyNameInput(); + const resourceLabelInput = getItemResourceLabelInput(); + if (policyIdInput) { + policyIdInput.value = ''; + } + if (policyNameInput) { + policyNameInput.value = ''; + } + if (resourceLabelInput) { + resourceLabelInput.value = ''; + } + if (allowAllInput) { + allowAllInput.checked = true; + } + if (usersInput) { + usersInput.value = ''; + } + if (groupsInput) { + groupsInput.value = ''; + } + applyItemAllowAllUiState(); + } + + await loadItemPolicies(); + showGovernanceToast('Delegated item policy deleted.', 'success'); +} + +function ensureGovernanceItemReviewPanel() { + const panelElement = document.getElementById('governance-item-policies-section'); + if (panelElement) { + wireGovernanceItemReviewHandlers(panelElement); + } + return panelElement; +} + +function openGovernanceItemReviewModal() { + const panelElement = ensureGovernanceItemReviewPanel(); + if (!panelElement) { + return; + } + + panelElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + loadGovernanceItemPolicyReview().catch((error) => { + setGovernanceStatus(error.message || 'Failed to load delegated item policies.', 'danger'); + }); +} + +function wireGovernanceItemReviewHandlers(panelElement) { + if (!panelElement || panelElement.dataset.reviewWired) { + return; + } + + const itemPolicyReviewBody = panelElement.querySelector('#governance-item-policies-review-body'); + if (!itemPolicyReviewBody) { + return; + } + + panelElement.dataset.reviewWired = 'true'; + itemPolicyReviewBody.addEventListener('click', async (event) => { + const target = event.target; + const editButton = target instanceof HTMLElement ? target.closest('.governance-edit-item-policy-btn') : null; + const deleteButton = target instanceof HTMLElement ? target.closest('.governance-delete-item-policy-btn') : null; + + if (deleteButton) { + openGovernanceItemPolicyDeleteModal( + deleteButton.dataset.entityType, + deleteButton.dataset.itemId, + deleteButton.dataset.policyId, + deleteButton.dataset.policyName, + ); + return; + } + + if (!editButton) { + return; + } + + const policy = { + entity_type: editButton.dataset.entityType || '', + item_id: editButton.dataset.itemId || '', + policy_id: editButton.dataset.policyId || '', + policy_name: editButton.dataset.policyName || '', + resource_label: editButton.dataset.resourceLabel || '', + allow_all: editButton.dataset.allowAll === 'true', + allowed_users: parseGovernancePrincipalDataset(editButton.dataset.allowedUsers), + allowed_groups: parseGovernancePrincipalDataset(editButton.dataset.allowedGroups), + }; + + try { + await openGovernanceItemPolicyEditor(policy); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to open delegated item policy editor.', 'danger'); + } + }); +} + +function syncGovernanceItemReviewControls() { + const searchInput = document.getElementById('governance-item-review-search'); + const entityTypeSelect = document.getElementById('governance-item-review-entity-type'); + const pageSizeSelect = document.getElementById('governance-item-review-page-size'); + + if (searchInput) { + searchInput.value = governanceItemReviewState.search; + } + if (entityTypeSelect) { + entityTypeSelect.value = governanceItemReviewState.entityType; + } + if (pageSizeSelect) { + pageSizeSelect.value = String(governanceItemReviewState.perPage); + } +} + +function renderGovernanceItemReviewRows(itemPolicies) { + const tbody = document.getElementById('governance-item-policies-review-body'); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(itemPolicies) || itemPolicies.length === 0) { + const emptyRow = document.createElement('tr'); + const emptyCell = document.createElement('td'); + emptyCell.colSpan = 7; + emptyCell.className = 'text-center text-muted'; + emptyCell.textContent = 'No delegated item policies found.'; + emptyRow.appendChild(emptyCell); + tbody.appendChild(emptyRow); + return; + } + + itemPolicies.forEach((policy) => { + tbody.appendChild(buildItemPolicyRow(policy)); + }); +} + +function updateGovernanceItemReviewSummary(pagination, totalVisible) { + const summary = document.getElementById('governance-item-review-summary'); + if (!summary) { + return; + } + + if (!pagination) { + summary.textContent = ''; + return; + } + + const currentStart = pagination.total_items === 0 ? 0 : ((pagination.page - 1) * pagination.per_page) + 1; + const currentEnd = pagination.total_items === 0 ? 0 : Math.min(pagination.page * pagination.per_page, pagination.total_items); + summary.textContent = `Showing ${currentStart}-${currentEnd} of ${pagination.total_items} configured item policy${pagination.total_items === 1 ? '' : 'ies'} (${totalVisible} on page ${pagination.page} of ${pagination.total_pages}).`; +} + +function updateGovernanceItemReviewPagination(pagination) { + const prevButton = document.getElementById('governance-item-review-prev-btn'); + const nextButton = document.getElementById('governance-item-review-next-btn'); + + if (prevButton) { + prevButton.disabled = !pagination || !pagination.has_prev; + } + if (nextButton) { + nextButton.disabled = !pagination || !pagination.has_next; + } +} + +async function loadGovernanceItemPolicyReview(page = governanceItemReviewState.page) { + const tbody = document.getElementById('governance-item-policies-review-body'); + if (!tbody) { + return; + } + + governanceItemReviewState.page = Math.max(1, Number(page) || 1); + + const params = new URLSearchParams(); + if (governanceItemReviewState.search) { + params.set('search', governanceItemReviewState.search); + } + if (governanceItemReviewState.entityType) { + params.set('entity_type', governanceItemReviewState.entityType); + } + params.set('page', String(governanceItemReviewState.page)); + params.set('per_page', String(governanceItemReviewState.perPage)); + + const response = await fetch(`/api/admin/governance/item-policies/review?${params.toString()}`, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Unable to load delegated item policy review data.'); + } + + const payload = await response.json(); + const itemPolicies = Array.isArray(payload.item_policies) ? payload.item_policies : []; + renderGovernanceItemReviewRows(itemPolicies); + updateGovernanceItemReviewSummary(payload.pagination, itemPolicies.length); + updateGovernanceItemReviewPagination(payload.pagination); +} + +function ensureGovernanceAllowListEditorModal() { + let modalElement = document.getElementById('governanceAllowListEditorModal'); + if (!modalElement) { + const modalMarkup = ` + + `; + + const wrapper = document.createElement('div'); + // xss-check: ignore - modalMarkup is a static Bootstrap shell; untrusted values are populated with DOM APIs. + wrapper.innerHTML = modalMarkup.trim(); + modalElement = wrapper.firstElementChild; + document.body.appendChild(modalElement); + } + + if (!governanceAllowListEditorModal) { + governanceAllowListEditorModal = bootstrap.Modal.getOrCreateInstance(modalElement); + } + + if (!modalElement.dataset.wired) { + modalElement.dataset.wired = 'true'; + wireGovernanceAllowListEditorHandlers(); + } + + return modalElement; +} + +function setGovernanceAllowListEditorStatus(message) { + const normalizedMessage = String(message || '').trim(); + if (!normalizedMessage) { + return; + } + + let variant = 'info'; + if (/failed|error|unable/i.test(normalizedMessage)) { + variant = 'danger'; + } else if (/no\s+csv|enter\s+a\s+user\s+search/i.test(normalizedMessage)) { + variant = 'warning'; + } else if (/added|removed|cleared|completed|updated/i.test(normalizedMessage)) { + variant = 'success'; + } + + showGovernanceToast(normalizedMessage, variant); +} + +function normalizeGovernanceAllowListPageSize(value) { + const parsed = Number(value) || GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT; + return GOVERNANCE_ALLOWLIST_PAGE_SIZES.includes(parsed) ? parsed : GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT; +} + +function resetGovernanceAllowListSelectionViewState() { + governanceAllowListSelectionViewState.users.search = ''; + governanceAllowListSelectionViewState.users.page = 1; + governanceAllowListSelectionViewState.users.pageSize = GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT; + + governanceAllowListSelectionViewState.groups.search = ''; + governanceAllowListSelectionViewState.groups.page = 1; + governanceAllowListSelectionViewState.groups.pageSize = GOVERNANCE_ALLOWLIST_PAGE_SIZE_DEFAULT; +} + +function syncGovernanceAllowListSelectionControls() { + const userSearchInput = document.getElementById('governance-allowlist-selected-user-search'); + const userPageSizeSelect = document.getElementById('governance-allowlist-selected-user-page-size'); + const groupSearchInput = document.getElementById('governance-allowlist-selected-group-search'); + const groupPageSizeSelect = document.getElementById('governance-allowlist-selected-group-page-size'); + + if (userSearchInput) { + userSearchInput.value = governanceAllowListSelectionViewState.users.search; + } + if (userPageSizeSelect) { + userPageSizeSelect.value = String(governanceAllowListSelectionViewState.users.pageSize); + } + if (groupSearchInput) { + groupSearchInput.value = governanceAllowListSelectionViewState.groups.search; + } + if (groupPageSizeSelect) { + groupPageSizeSelect.value = String(governanceAllowListSelectionViewState.groups.pageSize); + } +} + +function getGovernanceSelectedIdsByType(listType) { + if (!governanceAllowListEditorContext) { + return []; + } + if (listType === 'groups') { + return Array.isArray(governanceAllowListEditorContext.workingGroups) ? governanceAllowListEditorContext.workingGroups : []; + } + return Array.isArray(governanceAllowListEditorContext.workingUsers) ? governanceAllowListEditorContext.workingUsers : []; +} + +function getFilteredGovernanceSelectedIds(listType) { + const allIds = getGovernanceSelectedIdsByType(listType); + const state = governanceAllowListSelectionViewState[listType]; + const searchValue = String(state?.search || '').trim().toLowerCase(); + if (!searchValue) { + return allIds; + } + return allIds.filter((value) => { + const idText = String(value || '').toLowerCase(); + const displayName = String(getGovernanceDisplayName(listType, value) || '').toLowerCase(); + return idText.includes(searchValue) || displayName.includes(searchValue); + }); +} + +function truncateGovernanceId(idValue, maxLength = GOVERNANCE_ALLOWLIST_TRUNCATE_ID_LENGTH) { + const str = String(idValue || ''); + if (str.length <= maxLength) { + return str; + } + return str.substring(0, maxLength - 1) + '…'; +} + +function getGovernanceDisplayName(listType, idValue) { + const cache = governanceAllowListDisplayNameCache[listType] || {}; + return cache[idValue] || null; +} + +function setGovernanceDisplayName(listType, idValue, displayName) { + if (!governanceAllowListDisplayNameCache[listType]) { + governanceAllowListDisplayNameCache[listType] = {}; + } + governanceAllowListDisplayNameCache[listType][idValue] = String(displayName || '').trim(); +} + +function buildGovernanceUserLabel(user) { + const upn = String(user?.userPrincipalName || user?.mail || user?.email || '').trim(); + const displayName = String(user?.displayName || user?.display_name || upn || '(no name)').trim(); + if (upn && upn.toLowerCase() !== displayName.toLowerCase()) { + return `${displayName} (${upn})`; + } + return displayName; +} + +async function resolveGovernanceUserLabelById(userId) { + try { + const users = await governanceLookupUsers(userId); + const matchedUser = (Array.isArray(users) ? users : []).find((user) => String(user?.id || '').trim() === userId); + if (matchedUser) { + return buildGovernanceUserLabel(matchedUser); + } + } catch { + // Fall back to local user info below. + } + + try { + const response = await fetch(`/api/user/info/${encodeURIComponent(userId)}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return ''; + } + const payload = await response.json(); + return buildGovernanceUserLabel(payload || {}); + } catch { + return ''; + } +} + +async function resolveGovernanceGroupLabelById(groupId) { + try { + const groups = await governanceLookupGroups(groupId); + const matchedGroup = (Array.isArray(groups) ? groups : []).find((group) => String(group?.id || '').trim() === groupId); + if (!matchedGroup) { + return ''; + } + return String(matchedGroup.name || 'Unnamed Group').trim(); + } catch { + return ''; + } +} + +async function hydrateGovernanceDisplayNames(listType, ids) { + const normalizedIds = uniquePrincipalList(ids); + if (normalizedIds.length === 0) { + return; + } + + const inFlight = governanceAllowListHydrationState[listType] || new Set(); + const missingIds = normalizedIds.filter((idValue) => { + return idValue && !getGovernanceDisplayName(listType, idValue) && !inFlight.has(idValue); + }); + + if (missingIds.length === 0) { + return; + } + + missingIds.forEach((idValue) => inFlight.add(idValue)); + governanceAllowListHydrationState[listType] = inFlight; + + let hasUpdates = false; + await Promise.all(missingIds.map(async (idValue) => { + try { + const resolvedLabel = listType === 'users' + ? await resolveGovernanceUserLabelById(idValue) + : await resolveGovernanceGroupLabelById(idValue); + + if (resolvedLabel) { + setGovernanceDisplayName(listType, idValue, resolvedLabel); + hasUpdates = true; + } + } finally { + inFlight.delete(idValue); + } + })); + + if (hasUpdates) { + if (governanceAllowListEditorContext) { + renderGovernanceAllowListEditorSelections(); + } + if (document.getElementById('governance-item-policy-editor-modal')?.classList.contains('show')) { + renderGovernanceItemEditorSelections(); + } + } +} + +function renderGovernanceSelectedList(options) { + const { + listType, + containerId, + checkboxClass, + emptyMessage, + summaryId, + prevButtonId, + nextButtonId, + } = options; + + const tbody = document.getElementById(containerId); + const summary = document.getElementById(summaryId); + const prevButton = document.getElementById(prevButtonId); + const nextButton = document.getElementById(nextButtonId); + const state = governanceAllowListSelectionViewState[listType]; + + if (!tbody || !state) { + return; + } + + const filteredIds = getFilteredGovernanceSelectedIds(listType); + const pageSize = normalizeGovernanceAllowListPageSize(state.pageSize); + state.pageSize = pageSize; + const totalItems = filteredIds.length; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + state.page = Math.min(Math.max(1, state.page), totalPages); + const startIndex = (state.page - 1) * pageSize; + const visibleIds = filteredIds.slice(startIndex, startIndex + pageSize); + + tbody.innerHTML = ''; + if (visibleIds.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 3; + cell.className = 'text-center text-muted'; + cell.textContent = emptyMessage; + row.appendChild(cell); + tbody.appendChild(row); + } else { + visibleIds.forEach((idValue) => { + const row = document.createElement('tr'); + + const checkCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = checkboxClass; + checkbox.value = idValue; + checkCell.appendChild(checkbox); + + const displayName = getGovernanceDisplayName(listType, idValue); + const truncatedId = truncateGovernanceId(idValue); + const displayText = displayName ? `${displayName} (${truncatedId})` : truncatedId; + + const infoCell = document.createElement('td'); + infoCell.className = 'small'; + infoCell.textContent = displayText; + infoCell.title = idValue; + + const copyCell = document.createElement('td'); + copyCell.className = 'text-center'; + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.className = 'btn btn-sm btn-link p-0'; + setBootstrapIcon(copyButton, 'bi bi-clipboard'); + copyButton.title = 'Copy ID'; + copyButton.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + navigator.clipboard.writeText(idValue).then(() => { + setBootstrapIcon(copyButton, 'bi bi-check'); + setTimeout(() => { + setBootstrapIcon(copyButton, 'bi bi-clipboard'); + }, 1500); + }).catch(() => { + setBootstrapIcon(copyButton, 'bi bi-x'); + setTimeout(() => { + setBootstrapIcon(copyButton, 'bi bi-clipboard'); + }, 1500); + }); + }); + copyCell.appendChild(copyButton); + + row.appendChild(checkCell); + row.appendChild(infoCell); + row.appendChild(copyCell); + tbody.appendChild(row); + }); + } + + if (summary) { + const viewStart = totalItems === 0 ? 0 : startIndex + 1; + const viewEnd = totalItems === 0 ? 0 : Math.min(startIndex + visibleIds.length, totalItems); + summary.textContent = `Showing ${viewStart}-${viewEnd} of ${totalItems} selected (${state.page}/${totalPages}).`; + } + + if (prevButton) { + prevButton.disabled = state.page <= 1 || totalItems === 0; + } + if (nextButton) { + nextButton.disabled = state.page >= totalPages || totalItems === 0; + } +} + +function renderPrincipalIdRows(containerId, ids, checkboxClass, emptyMessage) { + const tbody = document.getElementById(containerId); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(ids) || ids.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 2; + cell.className = 'text-center text-muted'; + cell.textContent = emptyMessage; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + ids.forEach((idValue) => { + const row = document.createElement('tr'); + + const checkCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = checkboxClass; + checkbox.value = idValue; + checkCell.appendChild(checkbox); + + const idCell = document.createElement('td'); + idCell.textContent = idValue; + + row.appendChild(checkCell); + row.appendChild(idCell); + tbody.appendChild(row); + }); +} + +function renderGovernanceAllowListEditorSelections() { + if (!governanceAllowListEditorContext) { + return; + } + + const users = uniquePrincipalList(governanceAllowListEditorContext.workingUsers); + const groups = uniquePrincipalList(governanceAllowListEditorContext.workingGroups); + + void hydrateGovernanceDisplayNames('users', users); + void hydrateGovernanceDisplayNames('groups', groups); + + renderGovernanceSelectedList({ + listType: 'users', + containerId: 'governance-allowlist-selected-users', + checkboxClass: 'governance-selected-user-checkbox', + emptyMessage: 'No users selected.', + summaryId: 'governance-allowlist-selected-users-summary', + prevButtonId: 'governance-allowlist-selected-users-prev-btn', + nextButtonId: 'governance-allowlist-selected-users-next-btn', + }); + renderGovernanceSelectedList({ + listType: 'groups', + containerId: 'governance-allowlist-selected-groups', + checkboxClass: 'governance-selected-group-checkbox', + emptyMessage: 'No groups selected.', + summaryId: 'governance-allowlist-selected-groups-summary', + prevButtonId: 'governance-allowlist-selected-groups-prev-btn', + nextButtonId: 'governance-allowlist-selected-groups-next-btn', + }); +} + +function renderGovernanceAllowListUserResults(users) { + const tbody = document.getElementById('governance-allowlist-user-results'); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(users) || users.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 3; + cell.className = 'text-center text-muted'; + cell.textContent = 'No users found.'; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + users.forEach((user) => { + const userId = String(user.id || '').trim(); + const upn = String(user.userPrincipalName || user.mail || user.email || '').trim(); + const displayName = String(user.displayName || upn || '(no name)').trim(); + const userLabel = buildGovernanceUserLabel(user); + + if (userId && userLabel) { + setGovernanceDisplayName('users', userId, userLabel); + } + + const row = document.createElement('tr'); + + const selectCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'governance-user-result-checkbox'; + checkbox.value = userId; + checkbox.dataset.displayLabel = userLabel; + selectCell.appendChild(checkbox); + + const userCell = document.createElement('td'); + userCell.textContent = displayName; + + const emailCell = document.createElement('td'); + emailCell.textContent = upn; + + row.appendChild(selectCell); + row.appendChild(userCell); + row.appendChild(emailCell); + tbody.appendChild(row); + }); +} + +function renderGovernanceAllowListGroupResults(groups) { + const tbody = document.getElementById('governance-allowlist-group-results'); + if (!tbody) { + return; + } + + tbody.innerHTML = ''; + if (!Array.isArray(groups) || groups.length === 0) { + const row = document.createElement('tr'); + const cell = document.createElement('td'); + cell.colSpan = 3; + cell.className = 'text-center text-muted'; + cell.textContent = 'No groups found.'; + row.appendChild(cell); + tbody.appendChild(row); + return; + } + + groups.forEach((group) => { + const groupId = String(group.id || '').trim(); + const groupName = String(group.name || 'Unnamed Group'); + + if (groupId && groupName) { + setGovernanceDisplayName('groups', groupId, groupName); + } + + const row = document.createElement('tr'); + + const selectCell = document.createElement('td'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'governance-group-result-checkbox'; + checkbox.value = groupId; + checkbox.dataset.displayLabel = groupName; + selectCell.appendChild(checkbox); + + const nameCell = document.createElement('td'); + nameCell.textContent = groupName; + + const idCell = document.createElement('td'); + idCell.textContent = groupId; + + row.appendChild(selectCell); + row.appendChild(nameCell); + row.appendChild(idCell); + tbody.appendChild(row); + }); +} + +async function loadGovernanceAllowListUserResults() { + const searchInput = document.getElementById('governance-allowlist-user-search'); + const query = String(searchInput?.value || '').trim(); + if (!query) { + setGovernanceAllowListEditorStatus('Enter a user search term.'); + renderGovernanceAllowListUserResults([]); + return; + } + + const users = await governanceLookupUsers(query); + renderGovernanceAllowListUserResults(users); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus('User results updated.'); +} + +async function loadGovernanceAllowListGroupResults() { + const searchInput = document.getElementById('governance-allowlist-group-search'); + const query = String(searchInput?.value || '').trim(); + + const groups = await governanceLookupGroups(query); + renderGovernanceAllowListGroupResults(groups); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus('Group results updated.'); +} + +function applyGovernanceAllowListToContext() { + if (!governanceAllowListEditorContext) { + return; + } + + const users = uniquePrincipalList(governanceAllowListEditorContext.workingUsers); + const groups = uniquePrincipalList(governanceAllowListEditorContext.workingGroups); + governanceAllowListEditorContext.setValues(users, groups); + governanceAllowListEditorModal?.hide(); +} + +function openGovernanceAllowListEditor(context) { + if (!context || typeof context.getUsers !== 'function' || typeof context.getGroups !== 'function' || typeof context.setValues !== 'function') { + return; + } + + ensureGovernanceAllowListEditorModal(); + + governanceAllowListEditorContext = { + ...context, + workingUsers: uniquePrincipalList(context.getUsers()), + workingGroups: uniquePrincipalList(context.getGroups()), + }; + + const title = document.getElementById('governance-allowlist-editor-title'); + const contextAlert = document.getElementById('governance-allowlist-editor-context'); + const userSearchInput = document.getElementById('governance-allowlist-user-search'); + const groupSearchInput = document.getElementById('governance-allowlist-group-search'); + const csvInput = document.getElementById('governance-allowlist-csv-input'); + + if (title) { + title.textContent = context.title || 'Edit Allow List'; + } + if (contextAlert) { + contextAlert.textContent = context.description || 'Manage users and groups that are explicitly allowed for this policy.'; + } + if (userSearchInput) { + userSearchInput.value = ''; + } + if (groupSearchInput) { + groupSearchInput.value = ''; + } + if (csvInput) { + csvInput.value = ''; + } + + resetGovernanceAllowListSelectionViewState(); + syncGovernanceAllowListSelectionControls(); + + renderGovernanceAllowListUserResults([]); + renderGovernanceAllowListGroupResults([]); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(''); + + governanceAllowListEditorModal?.show(); +} + +function readCheckedValues(selector) { + return Array.from(document.querySelectorAll(selector)) + .filter((input) => input instanceof HTMLInputElement && input.checked) + .map((input) => String(input.value || '').trim()) + .filter((value) => value); +} + +function toggleCheckboxes(selector, checked) { + Array.from(document.querySelectorAll(selector)).forEach((input) => { + if (input instanceof HTMLInputElement) { + input.checked = checked; + } + }); +} + +function removeCheckedFromList(list, checkedValues) { + const valuesToRemove = new Set((checkedValues || []).map((value) => String(value || '').trim())); + return (Array.isArray(list) ? list : []).filter((value) => !valuesToRemove.has(String(value || '').trim())); +} + +function wireGovernanceAllowListEditorHandlers() { + const userSearchButton = document.getElementById('governance-allowlist-user-search-btn'); + if (userSearchButton) { + userSearchButton.addEventListener('click', async () => { + try { + await loadGovernanceAllowListUserResults(); + } catch (error) { + setGovernanceAllowListEditorStatus(error.message || 'Failed to load user results.'); + } + }); + } + + const groupSearchButton = document.getElementById('governance-allowlist-group-search-btn'); + if (groupSearchButton) { + groupSearchButton.addEventListener('click', async () => { + try { + await loadGovernanceAllowListGroupResults(); + } catch (error) { + setGovernanceAllowListEditorStatus(error.message || 'Failed to load group results.'); + } + }); + } + + const addSelectedUsersButton = document.getElementById('governance-allowlist-add-selected-users-btn'); + if (addSelectedUsersButton) { + addSelectedUsersButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + const selectedUserIds = readCheckedValues('.governance-user-result-checkbox'); + selectedUserIds.forEach((userId) => { + const userCheckbox = Array.from(document.querySelectorAll('.governance-user-result-checkbox')).find( + (checkbox) => checkbox.value === userId + ); + const displayLabel = String(userCheckbox?.dataset.displayLabel || '').trim(); + if (displayLabel) { + setGovernanceDisplayName('users', userId, displayLabel); + } + }); + governanceAllowListEditorContext.workingUsers = uniquePrincipalList([...governanceAllowListEditorContext.workingUsers, ...selectedUserIds]); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`Added ${selectedUserIds.length} user${selectedUserIds.length === 1 ? '' : 's'}.`); + }); + } + + const addSelectedGroupsButton = document.getElementById('governance-allowlist-add-selected-groups-btn'); + if (addSelectedGroupsButton) { + addSelectedGroupsButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + const selectedGroupIds = readCheckedValues('.governance-group-result-checkbox'); + selectedGroupIds.forEach((groupId) => { + const groupCheckbox = Array.from(document.querySelectorAll('.governance-group-result-checkbox')).find( + (checkbox) => checkbox.value === groupId + ); + const displayLabel = String(groupCheckbox?.dataset.displayLabel || '').trim(); + if (displayLabel) { + setGovernanceDisplayName('groups', groupId, displayLabel); + } + }); + governanceAllowListEditorContext.workingGroups = uniquePrincipalList([...governanceAllowListEditorContext.workingGroups, ...selectedGroupIds]); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`Added ${selectedGroupIds.length} group${selectedGroupIds.length === 1 ? '' : 's'}.`); + }); + } + + const removeSelectedUsersButton = document.getElementById('governance-allowlist-remove-selected-users-btn'); + if (removeSelectedUsersButton) { + removeSelectedUsersButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + const selectedUserIds = readCheckedValues('.governance-selected-user-checkbox'); + governanceAllowListEditorContext.workingUsers = removeCheckedFromList(governanceAllowListEditorContext.workingUsers, selectedUserIds); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`Removed ${selectedUserIds.length} user${selectedUserIds.length === 1 ? '' : 's'}.`); + }); + } + + const removeSelectedGroupsButton = document.getElementById('governance-allowlist-remove-selected-groups-btn'); + if (removeSelectedGroupsButton) { + removeSelectedGroupsButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + const selectedGroupIds = readCheckedValues('.governance-selected-group-checkbox'); + governanceAllowListEditorContext.workingGroups = removeCheckedFromList(governanceAllowListEditorContext.workingGroups, selectedGroupIds); + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`Removed ${selectedGroupIds.length} group${selectedGroupIds.length === 1 ? '' : 's'}.`); + }); + } + + const clearUsersButton = document.getElementById('governance-allowlist-clear-users-btn'); + if (clearUsersButton) { + clearUsersButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + governanceAllowListEditorContext.workingUsers = []; + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus('Cleared selected users.'); + }); + } + + const clearGroupsButton = document.getElementById('governance-allowlist-clear-groups-btn'); + if (clearGroupsButton) { + clearGroupsButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + governanceAllowListEditorContext.workingGroups = []; + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus('Cleared selected groups.'); + }); + } + + const csvApplyButton = document.getElementById('governance-allowlist-csv-apply-btn'); + if (csvApplyButton) { + csvApplyButton.addEventListener('click', () => { + if (!governanceAllowListEditorContext) { + return; + } + + const targetSelect = document.getElementById('governance-allowlist-csv-target'); + const modeSelect = document.getElementById('governance-allowlist-csv-mode'); + const csvInput = document.getElementById('governance-allowlist-csv-input'); + const target = String(targetSelect?.value || 'users'); + const mode = String(modeSelect?.value || 'merge'); + const importedValues = uniquePrincipalList(parseCsvPrincipalLines(csvInput?.value || '')); + + if (importedValues.length === 0) { + setGovernanceAllowListEditorStatus('No CSV values detected.'); + return; + } + + if (target === 'groups') { + governanceAllowListEditorContext.workingGroups = mode === 'replace' + ? importedValues + : uniquePrincipalList([...governanceAllowListEditorContext.workingGroups, ...importedValues]); + } else { + governanceAllowListEditorContext.workingUsers = mode === 'replace' + ? importedValues + : uniquePrincipalList([...governanceAllowListEditorContext.workingUsers, ...importedValues]); + } + + renderGovernanceAllowListEditorSelections(); + setGovernanceAllowListEditorStatus(`CSV ${mode} completed for ${target}. Imported ${importedValues.length} ID${importedValues.length === 1 ? '' : 's'}.`); + }); + } + + const saveButton = document.getElementById('governance-allowlist-save-btn'); + if (saveButton) { + saveButton.addEventListener('click', () => { + applyGovernanceAllowListToContext(); + }); + } + + const selectedUserSearchInput = document.getElementById('governance-allowlist-selected-user-search'); + if (selectedUserSearchInput) { + selectedUserSearchInput.addEventListener('input', () => { + governanceAllowListSelectionViewState.users.search = String(selectedUserSearchInput.value || '').trim(); + governanceAllowListSelectionViewState.users.page = 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedGroupSearchInput = document.getElementById('governance-allowlist-selected-group-search'); + if (selectedGroupSearchInput) { + selectedGroupSearchInput.addEventListener('input', () => { + governanceAllowListSelectionViewState.groups.search = String(selectedGroupSearchInput.value || '').trim(); + governanceAllowListSelectionViewState.groups.page = 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedUserPageSizeSelect = document.getElementById('governance-allowlist-selected-user-page-size'); + if (selectedUserPageSizeSelect) { + selectedUserPageSizeSelect.addEventListener('change', () => { + governanceAllowListSelectionViewState.users.pageSize = normalizeGovernanceAllowListPageSize(selectedUserPageSizeSelect.value); + governanceAllowListSelectionViewState.users.page = 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedGroupPageSizeSelect = document.getElementById('governance-allowlist-selected-group-page-size'); + if (selectedGroupPageSizeSelect) { + selectedGroupPageSizeSelect.addEventListener('change', () => { + governanceAllowListSelectionViewState.groups.pageSize = normalizeGovernanceAllowListPageSize(selectedGroupPageSizeSelect.value); + governanceAllowListSelectionViewState.groups.page = 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedUsersPrevButton = document.getElementById('governance-allowlist-selected-users-prev-btn'); + if (selectedUsersPrevButton) { + selectedUsersPrevButton.addEventListener('click', () => { + governanceAllowListSelectionViewState.users.page = Math.max(1, governanceAllowListSelectionViewState.users.page - 1); + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedUsersNextButton = document.getElementById('governance-allowlist-selected-users-next-btn'); + if (selectedUsersNextButton) { + selectedUsersNextButton.addEventListener('click', () => { + governanceAllowListSelectionViewState.users.page += 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedGroupsPrevButton = document.getElementById('governance-allowlist-selected-groups-prev-btn'); + if (selectedGroupsPrevButton) { + selectedGroupsPrevButton.addEventListener('click', () => { + governanceAllowListSelectionViewState.groups.page = Math.max(1, governanceAllowListSelectionViewState.groups.page - 1); + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectedGroupsNextButton = document.getElementById('governance-allowlist-selected-groups-next-btn'); + if (selectedGroupsNextButton) { + selectedGroupsNextButton.addEventListener('click', () => { + governanceAllowListSelectionViewState.groups.page += 1; + renderGovernanceAllowListEditorSelections(); + }); + } + + const selectAllMappings = [ + ['governance-allowlist-select-all-user-results', '.governance-user-result-checkbox'], + ['governance-allowlist-select-all-group-results', '.governance-group-result-checkbox'], + ['governance-allowlist-select-all-selected-users', '.governance-selected-user-checkbox'], + ['governance-allowlist-select-all-selected-groups', '.governance-selected-group-checkbox'], + ]; + + selectAllMappings.forEach(([masterId, checkboxSelector]) => { + const master = document.getElementById(masterId); + if (!master) { + return; + } + master.addEventListener('change', () => { + toggleCheckboxes(checkboxSelector, master.checked); + }); + }); +} + +async function loadItemPolicies() { + await loadGovernanceItemPolicyReview(1); +} + +async function saveItemPolicy(event) { + if (event && typeof event.preventDefault === 'function') { + event.preventDefault(); + } + + const entityTypeInput = document.getElementById('governance-item-entity-type'); + const itemIdInput = document.getElementById('governance-item-id'); + const policyIdInput = getItemPolicyIdInput(); + const policyNameInput = getItemPolicyNameInput(); + const resourceLabelInput = getItemResourceLabelInput(); + const allowAllInput = document.getElementById('governance-item-allow-all'); + const usersInput = document.getElementById('governance-item-users'); + const groupsInput = document.getElementById('governance-item-groups'); + + if (!entityTypeInput || !itemIdInput || !allowAllInput || !usersInput || !groupsInput) { + return; + } + + const entityType = normalizeGovernanceItemEntityType(entityTypeInput.value); + const itemId = String(itemIdInput.value || '').trim(); + const resourceLabel = String(resourceLabelInput?.value || '').trim(); + const policyName = String(policyNameInput?.value || '').trim() || buildDefaultItemPolicyName(entityType, itemId, resourceLabel); + + if (!entityType || !itemId) { + setGovernanceItemEditorStatus('Entity type and item ID are required for item governance policies.', 'warning'); + return; + } + + if (!policyName) { + setGovernanceItemEditorStatus('Policy name is required for delegated item policies.', 'warning'); + return; + } + + const payload = { + policy_id: String(policyIdInput?.value || '').trim(), + policy_name: policyName, + resource_label: resourceLabel, + allow_all: allowAllInput.checked, + allowed_users: allowAllInput.checked ? [] : splitPrincipalList(usersInput.value), + allowed_groups: allowAllInput.checked ? [] : splitPrincipalList(groupsInput.value), + }; + + const response = await fetch( + `/api/admin/governance/item-policies/${encodeURIComponent(entityType)}/${encodeURIComponent(itemId)}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + } + ); + + if (!response.ok) { + throw new Error('Unable to save item governance policy.'); + } + + await loadItemPolicies(); + updateItemAllowListSummary(); + governanceItemPolicyEditorModal?.hide(); + clearGovernanceStatus(); + showGovernanceToast('Item governance policy saved successfully.', 'success'); +} + +function wireGovernanceHandlers() { + Object.keys(GOVERNANCE_FEATURE_LABELS).forEach((featureKey) => { + const featureToggle = getGovernanceFeatureToggle(featureKey); + if (!featureToggle) { + return; + } + featureToggle.addEventListener('change', () => { + syncGovernanceFeaturePolicyVisibility(); + }); + }); + + Object.values(GOVERNANCE_PRIMARY_TOGGLE_MAP).forEach((primaryToggleId) => { + const primaryToggle = document.getElementById(primaryToggleId); + if (!(primaryToggle instanceof HTMLInputElement)) { + return; + } + primaryToggle.addEventListener('change', () => { + syncGovernanceFeaturePolicyVisibility(); + }); + }); + + document.querySelectorAll('.governance-primary-link').forEach((linkButton) => { + linkButton.addEventListener('click', () => { + const targetId = String(linkButton.getAttribute('data-governance-target') || '').trim(); + if (typeof window.openAdminSettingsTab === 'function') { + window.openAdminSettingsTab('#governance'); + } + window.setTimeout(() => { + const target = document.getElementById(targetId); + const wrapper = target?.closest('.form-check'); + if (wrapper?.classList.contains('d-none')) { + setGovernanceStatus('Enable the matching primary feature before configuring governance for it.', 'warning'); + return; + } + target?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + target?.focus(); + }, 100); + }); + }); + + const featurePolicyTableBody = document.getElementById('governance-feature-policies-body'); + if (featurePolicyTableBody) { + featurePolicyTableBody.addEventListener('change', (event) => { + const target = event.target; + if (!(target instanceof HTMLInputElement)) { + return; + } + if (target.classList.contains('governance-allow-all')) { + const row = target.closest('tr'); + applyFeatureAllowAllUiState(row); + } + }); + + featurePolicyTableBody.addEventListener('click', (event) => { + const target = event.target; + const editButton = target instanceof HTMLElement ? target.closest('.governance-edit-feature-allowlist-btn') : null; + if (!editButton) { + return; + } + + const row = editButton.closest('tr'); + const usersInput = getGovernanceUsersInputForFeatureRow(row); + const groupsInput = getGovernanceGroupsInputForFeatureRow(row); + const featureKey = String(row?.dataset?.featureKey || '').trim(); + if (!usersInput || !groupsInput || !featureKey) { + return; + } + + openGovernanceAllowListEditor({ + title: `Edit Allow List: ${GOVERNANCE_FEATURE_LABELS[featureKey] || featureKey}`, + description: 'Manage explicitly allowed users and groups for this feature policy.', + getUsers: () => splitPrincipalList(usersInput.value), + getGroups: () => splitPrincipalList(groupsInput.value), + setValues: (users, groups) => { + usersInput.value = joinPrincipalList(users); + groupsInput.value = joinPrincipalList(groups); + const allowAllInput = getGovernanceFeatureAllowAllInput(row); + if (allowAllInput) { + allowAllInput.checked = false; + } + applyFeatureAllowAllUiState(row); + }, + }); + }); + } + + const saveFeaturePoliciesButton = document.getElementById('governance-save-feature-policies-btn'); + if (saveFeaturePoliciesButton) { + saveFeaturePoliciesButton.addEventListener('click', async () => { + clearGovernanceStatus(); + try { + await saveFeaturePolicies(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to save feature policies.', 'danger'); + } + }); + } + + const newItemPolicyButton = document.getElementById('governance-new-item-policy-btn'); + if (newItemPolicyButton) { + newItemPolicyButton.addEventListener('click', async () => { + clearGovernanceStatus(); + try { + await openGovernanceItemPolicyEditor(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to open delegated item policy editor.', 'danger'); + } + }); + } + + const refreshItemPoliciesButton = document.getElementById('governance-refresh-item-policies-btn'); + if (refreshItemPoliciesButton) { + refreshItemPoliciesButton.addEventListener('click', async () => { + clearGovernanceStatus(); + try { + await loadItemPolicies(); + setGovernanceStatus('Item governance policies refreshed.', 'info'); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to refresh item policies.', 'danger'); + } + }); + } + + const reviewSearchButton = document.getElementById('governance-item-review-search-btn'); + if (reviewSearchButton) { + reviewSearchButton.addEventListener('click', async () => { + const searchInput = document.getElementById('governance-item-review-search'); + const entityTypeSelect = document.getElementById('governance-item-review-entity-type'); + const pageSizeSelect = document.getElementById('governance-item-review-page-size'); + governanceItemReviewState.search = String(searchInput?.value || '').trim(); + governanceItemReviewState.entityType = String(entityTypeSelect?.value || '').trim(); + governanceItemReviewState.perPage = Math.max(1, Number(pageSizeSelect?.value || GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE) || GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE); + governanceItemReviewState.page = 1; + syncGovernanceItemReviewControls(); + try { + await loadGovernanceItemPolicyReview(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to search delegated item policies.', 'danger'); + } + }); + } + + const reviewResetButton = document.getElementById('governance-item-review-reset-btn'); + if (reviewResetButton) { + reviewResetButton.addEventListener('click', async () => { + governanceItemReviewState.search = ''; + governanceItemReviewState.entityType = ''; + governanceItemReviewState.page = 1; + governanceItemReviewState.perPage = GOVERNANCE_ITEM_REVIEW_DEFAULT_PAGE_SIZE; + syncGovernanceItemReviewControls(); + try { + await loadGovernanceItemPolicyReview(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to reset delegated item policy filters.', 'danger'); + } + }); + } + + const reviewPrevButton = document.getElementById('governance-item-review-prev-btn'); + if (reviewPrevButton) { + reviewPrevButton.addEventListener('click', async () => { + if (governanceItemReviewState.page <= 1) { + return; + } + governanceItemReviewState.page -= 1; + syncGovernanceItemReviewControls(); + try { + await loadGovernanceItemPolicyReview(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to load previous delegated item policy page.', 'danger'); + } + }); + } + + const reviewNextButton = document.getElementById('governance-item-review-next-btn'); + if (reviewNextButton) { + reviewNextButton.addEventListener('click', async () => { + governanceItemReviewState.page += 1; + syncGovernanceItemReviewControls(); + try { + await loadGovernanceItemPolicyReview(); + } catch (error) { + setGovernanceStatus(error.message || 'Failed to load next delegated item policy page.', 'danger'); + } + }); + } +} + +async function initializeGovernanceTab() { + if (!document.getElementById('governance')) { + return; + } + + wireGovernanceHandlers(); + clearGovernanceStatus(); + + ensureGovernanceItemReviewPanel(); + ensureGovernanceAllowListEditorModal(); + ensureGovernanceItemPolicyEditorModal(); + + try { + await loadFeaturePolicies(); + await loadItemPolicies(); + } catch (error) { + setGovernanceStatus(error.message || 'Unable to initialize governance settings.', 'danger'); + } +} + +document.addEventListener('DOMContentLoaded', () => { + initializeGovernanceTab(); +}); diff --git a/application/single_app/static/js/admin/admin_model_endpoints.js b/application/single_app/static/js/admin/admin_model_endpoints.js index 2943795de..c6ae24451 100644 --- a/application/single_app/static/js/admin/admin_model_endpoints.js +++ b/application/single_app/static/js/admin/admin_model_endpoints.js @@ -10,6 +10,10 @@ const endpointsInput = document.getElementById("model_endpoints_json"); const defaultModelSelect = document.getElementById("default-model-selection"); const defaultModelInput = document.getElementById("default_model_selection_json"); const defaultModelWrapper = document.getElementById("default-model-selection-wrapper"); +const metadataExtractionModelSelect = document.getElementById("metadata_extraction_model"); +const metadataExtractionModelInput = document.getElementById("metadata_extraction_model_selection_json"); +const legacyGptApimToggle = document.getElementById("enable_gpt_apim"); +const legacyApimGptDeploymentInput = document.getElementById("azure_apim_gpt_deployment"); const migrationPanel = document.getElementById("agent-default-model-migration-panel"); const migrationStatus = document.getElementById("agent-default-model-migration-status"); const migrationCallout = document.getElementById("agent-default-model-migration-callout"); @@ -81,9 +85,15 @@ let modelEndpoints = Array.isArray(window.modelEndpoints) ? [...window.modelEndp let modalModels = []; let pendingDeleteEndpointId = null; let pendingDeleteTimeout = null; +let pendingEndpointDuplicate = null; +let endpointDuplicateKeyModal = null; let defaultModelSelection = window.defaultModelSelection && typeof window.defaultModelSelection === "object" ? { ...window.defaultModelSelection } : {}; +let metadataExtractionModelSelection = window.metadataExtractionModelSelection && typeof window.metadataExtractionModelSelection === "object" + ? { ...window.metadataExtractionModelSelection } + : {}; +const legacyMetadataExtractionModel = String(window.legacyMetadataExtractionModel || "").trim(); let migrationPreviewState = null; let migrationSelectedKeys = new Set(); @@ -132,6 +142,21 @@ function updateDefaultModelInput() { defaultModelInput.value = JSON.stringify(defaultModelSelection || {}); } +function updateMetadataExtractionModelInput() { + if (!metadataExtractionModelInput) { + return; + } + metadataExtractionModelInput.value = JSON.stringify(metadataExtractionModelSelection || {}); +} + +function isMultiEndpointModeEnabled() { + if (enableMultiEndpointToggle) { + return !!enableMultiEndpointToggle.checked; + } + + return window.enableMultiModelEndpoints === true || window.enableMultiModelEndpoints === "true"; +} + function isAdminSettingsFormModified() { return typeof window.isAdminSettingsFormModified === "function" && window.isAdminSettingsFormModified(); } @@ -270,6 +295,7 @@ function renderEndpoints() { `; updateHiddenInput(); buildDefaultModelOptions(); + buildMetadataExtractionModelOptions(); return; } @@ -280,30 +306,98 @@ function renderEndpoints() { const statusClass = endpoint.enabled ? "success" : "secondary"; const toggleLabel = endpoint.enabled ? "Disable" : "Enable"; - row.innerHTML = ` - -
${escapeHtml(endpoint.name || "Unnamed Endpoint")}
-
${escapeHtml(endpoint.connection?.endpoint || "")}
- - ${escapeHtml(formatProviderLabel(endpoint.provider))} - - ${escapeHtml(selectedModels)} - - ${statusLabel} - -
- - - -
- - `; + const endpointCell = document.createElement("td"); + const endpointName = document.createElement("div"); + endpointName.className = "fw-semibold"; + endpointName.textContent = endpoint.name || "Unnamed Endpoint"; + const endpointUrl = document.createElement("div"); + endpointUrl.className = "text-muted small"; + endpointUrl.textContent = endpoint.connection?.endpoint || ""; + endpointCell.appendChild(endpointName); + endpointCell.appendChild(endpointUrl); + + const providerCell = document.createElement("td"); + providerCell.textContent = formatProviderLabel(endpoint.provider); + + const modelsCell = document.createElement("td"); + const modelsSpan = document.createElement("span"); + modelsSpan.title = selectedModels; + modelsSpan.textContent = selectedModels; + modelsCell.appendChild(modelsSpan); + + const statusCell = document.createElement("td"); + const statusBadge = document.createElement("span"); + statusBadge.className = `badge bg-${statusClass}`; + statusBadge.textContent = statusLabel; + statusCell.appendChild(statusBadge); + + const actionsCell = document.createElement("td"); + actionsCell.className = "text-end"; + const actionsGroup = document.createElement("div"); + actionsGroup.className = "btn-group btn-group-sm"; + actionsGroup.setAttribute("role", "group"); + + const createEndpointButton = (action, label, className) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.dataset.action = action; + button.dataset.endpointId = endpoint.id || ""; + button.textContent = label; + return button; + }; + + actionsGroup.appendChild(createEndpointButton("edit", "Edit", "btn btn-outline-primary")); + actionsGroup.appendChild(createEndpointButton("govern", "Govern", "btn btn-outline-info")); + actionsGroup.appendChild(createEndpointButton("duplicate", "Duplicate", "btn btn-outline-secondary")); + actionsGroup.appendChild(createEndpointButton("toggle", toggleLabel, `btn btn-outline-${endpoint.enabled ? "warning" : "success"}`)); + actionsGroup.appendChild(createEndpointButton("delete", "Delete", "btn btn-outline-danger")); + actionsCell.appendChild(actionsGroup); + + row.appendChild(endpointCell); + row.appendChild(providerCell); + row.appendChild(modelsCell); + row.appendChild(statusCell); + row.appendChild(actionsCell); endpointsTbody.appendChild(row); }); updateHiddenInput(); buildDefaultModelOptions(); + buildMetadataExtractionModelOptions(); +} + +function buildEndpointModelOption(endpoint, model) { + const modelId = model.id + || model.deploymentName + || model.deployment + || model.modelName + || model.name + || generateId(); + if (!model.id) { + model.id = modelId; + } + + const endpointLabel = endpoint.name || endpoint.connection?.endpoint || "Endpoint"; + const provider = (endpoint.provider || "aoai").toLowerCase(); + const providerLabel = formatProviderLabel(provider); + const endpointEnabled = endpoint.enabled !== false; + const modelEnabled = model.enabled !== false; + const modelLabel = model.displayName || model.deploymentName || model.modelName || "Unnamed model"; + const option = document.createElement("option"); + option.value = `${endpoint.id}:${modelId}`; + option.dataset.endpointId = endpoint.id || ""; + option.dataset.modelId = modelId || ""; + option.dataset.provider = provider; + option.dataset.deploymentName = model.deploymentName || model.deployment || ""; + option.disabled = !(endpointEnabled && modelEnabled); + option.textContent = `${endpointLabel} — ${modelLabel} (${providerLabel})`; + if (option.disabled) { + option.textContent += " (disabled)"; + } + + return option; } function buildDefaultModelOptions() { @@ -320,40 +414,118 @@ function buildDefaultModelOptions() { modelEndpoints.forEach((endpoint) => { const models = Array.isArray(endpoint.models) ? endpoint.models : []; - const endpointLabel = endpoint.name || endpoint.connection?.endpoint || "Endpoint"; - const provider = (endpoint.provider || "aoai").toLowerCase(); - const providerLabel = formatProviderLabel(provider); - const endpointEnabled = endpoint.enabled !== false; models.forEach((model) => { - const modelId = model.id - || model.deploymentName - || model.deployment - || model.modelName - || model.name - || generateId(); - if (!model.id) { - model.id = modelId; - } - const modelEnabled = model.enabled !== false; - const modelLabel = model.displayName || model.deploymentName || model.modelName || "Unnamed model"; - const option = document.createElement("option"); - option.value = `${endpoint.id}:${modelId}`; - option.dataset.endpointId = endpoint.id || ""; - option.dataset.modelId = modelId || ""; - option.dataset.provider = provider; - option.disabled = !(endpointEnabled && modelEnabled); - option.textContent = `${endpointLabel} — ${modelLabel} (${providerLabel})`; - if (option.disabled) { - option.textContent += " (disabled)"; - } - defaultModelSelect.appendChild(option); + defaultModelSelect.appendChild(buildEndpointModelOption(endpoint, model)); }); }); applyDefaultModelSelection(defaultModelSelection); } +function buildMetadataExtractionModelOptions() { + if (!metadataExtractionModelSelect) { + return; + } + + metadataExtractionModelSelect.innerHTML = ""; + + const emptyOption = document.createElement("option"); + emptyOption.value = ""; + emptyOption.textContent = "No metadata extraction model selected"; + metadataExtractionModelSelect.appendChild(emptyOption); + + if (!isMultiEndpointModeEnabled()) { + buildLegacyMetadataExtractionModelOptions(); + return; + } + + modelEndpoints.forEach((endpoint) => { + const models = Array.isArray(endpoint.models) ? endpoint.models : []; + models.forEach((model) => { + metadataExtractionModelSelect.appendChild(buildEndpointModelOption(endpoint, model)); + }); + }); + + applyMetadataExtractionModelSelection(metadataExtractionModelSelection); +} + +function buildLegacyMetadataExtractionModelOptions() { + metadataExtractionModelSelection = { + endpoint_id: "", + model_id: "", + provider: "" + }; + updateMetadataExtractionModelInput(); + + if (legacyGptApimToggle?.checked) { + const deployments = String(legacyApimGptDeploymentInput?.value || "") + .split(",") + .map((deployment) => deployment.trim()) + .filter(Boolean); + + deployments.forEach((deployment) => { + metadataExtractionModelSelect.add(new Option(deployment, deployment)); + }); + } else { + const legacyModels = Array.isArray(window.gptSelected) ? window.gptSelected : []; + legacyModels.forEach((model) => { + const deploymentName = model?.deploymentName || ""; + if (!deploymentName) { + return; + } + const modelName = model?.modelName || deploymentName; + metadataExtractionModelSelect.add(new Option(`${deploymentName} (${modelName})`, deploymentName)); + }); + } + + if (legacyMetadataExtractionModel) { + metadataExtractionModelSelect.value = legacyMetadataExtractionModel; + } +} + +function applyMetadataExtractionModelSelection(selection) { + if (!metadataExtractionModelSelect) { + return; + } + + metadataExtractionModelSelection = normalizeDefaultModelSelection(selection); + const hasSelection = metadataExtractionModelSelection.endpoint_id && metadataExtractionModelSelection.model_id; + let matchingOption = null; + + if (hasSelection) { + matchingOption = Array.from(metadataExtractionModelSelect.options).find((option) => { + return option.dataset.endpointId === metadataExtractionModelSelection.endpoint_id + && option.dataset.modelId === metadataExtractionModelSelection.model_id; + }); + } else if (legacyMetadataExtractionModel) { + matchingOption = Array.from(metadataExtractionModelSelect.options).find((option) => { + return option.dataset.deploymentName === legacyMetadataExtractionModel; + }); + if (matchingOption && !matchingOption.disabled) { + metadataExtractionModelSelection = { + endpoint_id: matchingOption.dataset.endpointId || "", + model_id: matchingOption.dataset.modelId || "", + provider: matchingOption.dataset.provider || "" + }; + } + } + + if (!matchingOption || matchingOption.disabled) { + metadataExtractionModelSelection = { + endpoint_id: "", + model_id: "", + provider: "" + }; + metadataExtractionModelSelect.value = ""; + updateMetadataExtractionModelInput(); + return; + } + + metadataExtractionModelSelect.value = matchingOption.value; + updateMetadataExtractionModelInput(); +} + function applyDefaultModelSelection(selection) { if (!defaultModelSelect) { return; @@ -413,6 +585,40 @@ function handleDefaultModelChange() { handleMigrationConfigurationChange(); } +function handleMetadataExtractionModelChange() { + if (!metadataExtractionModelSelect) { + return; + } + + if (!isMultiEndpointModeEnabled()) { + metadataExtractionModelSelection = { + endpoint_id: "", + model_id: "", + provider: "" + }; + updateMetadataExtractionModelInput(); + markModified(); + return; + } + + const selectedOption = metadataExtractionModelSelect.selectedOptions[0]; + if (!selectedOption || !selectedOption.value) { + metadataExtractionModelSelection = { + endpoint_id: "", + model_id: "", + provider: "" + }; + } else { + metadataExtractionModelSelection = { + endpoint_id: selectedOption.dataset.endpointId || "", + model_id: selectedOption.dataset.modelId || "", + provider: selectedOption.dataset.provider || "" + }; + } + updateMetadataExtractionModelInput(); + markModified(); +} + function updateAuthVisibility() { const authType = endpointAuthTypeSelect?.value || "managed_identity"; const provider = endpointProviderSelect?.value || "aoai"; @@ -438,6 +644,9 @@ function updateAuthVisibility() { } function resetModal() { + if (endpointModalEl) { + endpointModalEl.dataset.duplicateDisabledDefault = ''; + } if (endpointIdInput) endpointIdInput.value = ""; if (endpointNameInput) endpointNameInput.value = ""; if (endpointProviderSelect) endpointProviderSelect.value = "aoai"; @@ -515,6 +724,106 @@ function openModalForEndpoint(endpoint) { endpointModal.show(); } +function makeEndpointCopyName(name) { + const baseName = `${String(name || 'Endpoint').trim() || 'Endpoint'} Copy`; + const existingNames = new Set((modelEndpoints || []).map((endpoint) => String(endpoint.name || '').trim().toLowerCase())); + if (!existingNames.has(baseName.toLowerCase())) { + return baseName; + } + + let suffix = 2; + while (existingNames.has(`${baseName} ${suffix}`.toLowerCase())) { + suffix += 1; + } + return `${baseName} ${suffix}`; +} + +function cloneEndpointForDuplicate(endpoint) { + const duplicate = JSON.parse(JSON.stringify(endpoint || {})); + duplicate.id = generateId(); + duplicate.name = makeEndpointCopyName(endpoint?.name || 'Endpoint'); + duplicate.enabled = false; + duplicate.models = Array.isArray(duplicate.models) + ? duplicate.models.map((model) => ({ ...model, id: generateId() })) + : []; + + if (duplicate.auth?.type === 'api_key') { + duplicate.auth.api_key = ''; + duplicate.has_api_key = false; + } + + return duplicate; +} + +function ensureEndpointDuplicateKeyModal() { + let modalElement = document.getElementById('endpoint-duplicate-key-confirm-modal'); + if (!modalElement) { + const modalMarkup = ` + + `; + const wrapper = document.createElement('div'); + // xss-check: ignore - modalMarkup is a static Bootstrap shell; untrusted values are not interpolated. + wrapper.innerHTML = modalMarkup.trim(); + modalElement = wrapper.firstElementChild; + document.body.appendChild(modalElement); + } + + if (!endpointDuplicateKeyModal) { + endpointDuplicateKeyModal = bootstrap.Modal.getOrCreateInstance(modalElement); + } + + if (!modalElement.dataset.wired) { + modalElement.dataset.wired = 'true'; + modalElement.querySelector('#endpoint-duplicate-key-confirm-btn')?.addEventListener('click', () => { + const duplicate = pendingEndpointDuplicate; + pendingEndpointDuplicate = null; + endpointDuplicateKeyModal?.hide(); + if (duplicate) { + openDuplicateEndpointModal(duplicate); + } + }); + } + + return modalElement; +} + +function openDuplicateEndpointModal(duplicateEndpoint) { + openModalForEndpoint(duplicateEndpoint); + if (endpointModalEl) { + endpointModalEl.dataset.duplicateDisabledDefault = 'true'; + } + showToast('Duplicated endpoint starts disabled. Review and save it, then enable intentionally.', 'info'); +} + +function duplicateEndpoint(endpoint) { + const duplicate = cloneEndpointForDuplicate(endpoint); + if (endpoint?.auth?.type === 'api_key' || endpoint?.has_api_key) { + pendingEndpointDuplicate = duplicate; + ensureEndpointDuplicateKeyModal(); + endpointDuplicateKeyModal?.show(); + return; + } + + openDuplicateEndpointModal(duplicate); +} + function renderModalModels(models) { if (!modelsListEl) { return; @@ -792,7 +1101,9 @@ function saveEndpoint() { id: endpointId, name: payload.name, provider: payload.provider, - enabled: true, + enabled: endpointModalEl?.dataset.duplicateDisabledDefault === 'true' + ? false + : (existingEndpoint ? existingEndpoint.enabled !== false : true), auth: payload.auth, connection: payload.connection, management: payload.management, @@ -873,6 +1184,24 @@ function handleTableClick(event) { return; } + if (action === "govern") { + if (typeof window.openGovernanceDelegatedItemEditor === 'function') { + window.openGovernanceDelegatedItemEditor({ + entityType: 'global_endpoint', + itemId: endpoint.id, + resourceLabel: endpoint.name || endpoint.id, + }); + } else { + showToast('Governance editor is still loading. Try again in a moment.', 'warning'); + } + return; + } + + if (action === "duplicate") { + duplicateEndpoint(endpoint); + return; + } + if (action === "toggle") { endpoint.enabled = !endpoint.enabled; renderEndpoints(); @@ -914,6 +1243,7 @@ function handleToggleChange() { if (!enabled) { setElementVisibility(migrationResults, false); } + buildMetadataExtractionModelOptions(); markModified(); handleMigrationConfigurationChange(); } @@ -1339,6 +1669,18 @@ function init() { defaultModelSelect.addEventListener("change", handleDefaultModelChange); } + if (metadataExtractionModelSelect) { + metadataExtractionModelSelect.addEventListener("change", handleMetadataExtractionModelChange); + } + + if (legacyGptApimToggle) { + legacyGptApimToggle.addEventListener("change", buildMetadataExtractionModelOptions); + } + + if (legacyApimGptDeploymentInput) { + legacyApimGptDeploymentInput.addEventListener("input", buildMetadataExtractionModelOptions); + } + if (previewMigrationBtn) { previewMigrationBtn.addEventListener("click", () => { openMigrationReviewModal(); diff --git a/application/single_app/static/js/admin/admin_plugins.js b/application/single_app/static/js/admin/admin_plugins.js index 97dc39ec0..c684906e4 100644 --- a/application/single_app/static/js/admin/admin_plugins.js +++ b/application/single_app/static/js/admin/admin_plugins.js @@ -26,6 +26,8 @@ async function loadPlugins() { tbodySelector: '#admin-plugins-table-body', onEdit: name => editPlugin(name), onDelete: name => deletePlugin(name), + onGovern: name => governPlugin(name, plugins), + onDuplicate: name => duplicatePlugin(name, plugins), ensureTable: false, isAdmin: true }); @@ -47,6 +49,61 @@ function openPluginModal(plugin = null) { } } +function makePluginCopyName(name, plugins = []) { + const baseName = `${String(name || 'action').trim() || 'action'}_copy`; + const existingNames = new Set((plugins || []).map((plugin) => String(plugin.name || '').trim().toLowerCase())); + if (!existingNames.has(baseName.toLowerCase())) { + return baseName; + } + + let suffix = 2; + while (existingNames.has(`${baseName}_${suffix}`.toLowerCase())) { + suffix += 1; + } + return `${baseName}_${suffix}`; +} + +function duplicatePlugin(name, plugins = []) { + const plugin = (plugins || []).find(p => p.name === name); + if (!plugin) { + showToast(`Action "${name}" not found`, 'danger'); + return; + } + + const duplicate = JSON.parse(JSON.stringify(plugin)); + delete duplicate.id; + duplicate.name = makePluginCopyName(plugin.name, plugins); + duplicate.display_name = `${plugin.display_name || plugin.name || 'Action'} Copy`; + const modal = window.pluginModalStepper?.showModal(duplicate); + if (window.pluginModalStepper) { + window.pluginModalStepper.isEditMode = false; + window.pluginModalStepper.originalPlugin = null; + const title = document.getElementById('plugin-modal-title'); + if (title) { + title.textContent = 'Add Action'; + } + } + setupSaveHandler(null, modal); +} + +function governPlugin(name, plugins = []) { + const plugin = (plugins || []).find(p => p.name === name); + const pluginId = String(plugin?.id || '').trim(); + if (!pluginId) { + showToast('This action does not have a stable ID for governance.', 'warning'); + return; + } + if (typeof window.openGovernanceDelegatedItemEditor === 'function') { + window.openGovernanceDelegatedItemEditor({ + entityType: 'global_action', + itemId: pluginId, + resourceLabel: plugin.display_name || plugin.name || pluginId, + }); + } else { + showToast('Governance editor is still loading. Try again in a moment.', 'warning'); + } +} + function setupSaveHandler(plugin, modal) { const saveBtn = document.getElementById('save-plugin-btn'); if (saveBtn) { diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 7861b8017..657fede79 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -3675,37 +3675,6 @@ if (audioServiceDiv) { // Metadata Extraction UI const extractToggle = document.getElementById('enable_extract_meta_data'); const extractModelDiv = document.getElementById('metadata_extraction_model_settings'); -const extractSelect = document.getElementById('metadata_extraction_model'); - -function populateExtractionModels() { - // remember previously chosen value - const prev = extractSelect.getAttribute('data-prev') || ''; - - // clear out old options - extractSelect.innerHTML = ''; - - if (document.getElementById('enable_gpt_apim').checked) { - // use comma-separated APIM deployments - const text = document.getElementById('azure_apim_gpt_deployment').value || ''; - text.split(',') - .map(s => s.trim()) - .filter(s => s) - .forEach(d => { - const opt = new Option(d, d); - extractSelect.add(opt); - }); - } else { - // use direct GPT selected deployments - (window.gptSelected || []).forEach(m => { - const label = `${m.deploymentName} (${m.modelName})`; - const opt = new Option(label, m.deploymentName); - extractSelect.add(opt); - }); - } - - // restore previous - extractSelect.value = prev; -} if (extractToggle) { // show/hide the model dropdown @@ -3798,17 +3767,12 @@ if (visionSelect) { const apimToggle = document.getElementById('enable_gpt_apim'); if (apimToggle) { apimToggle.addEventListener('change', () => { - populateExtractionModels(); populateVisionModels(); }); } // on load, stash previous & populate document.addEventListener('DOMContentLoaded', () => { - if (extractSelect) { - extractSelect.setAttribute('data-prev', extractSelect.value); - populateExtractionModels(); - } if (visionSelect) { visionSelect.setAttribute('data-prev', visionSelect.value); populateVisionModels(); diff --git a/application/single_app/static/js/admin/admin_sidebar_nav.js b/application/single_app/static/js/admin/admin_sidebar_nav.js index 3c3a17505..88a98183f 100644 --- a/application/single_app/static/js/admin/admin_sidebar_nav.js +++ b/application/single_app/static/js/admin/admin_sidebar_nav.js @@ -183,6 +183,7 @@ function scrollToSection(sectionId) { 'home-page-text-section': 'home-page-text-section', 'appearance-section': 'appearance-section', 'classification-banner-section': 'classification-banner-section', + 'custom-pages-section': 'custom-pages-section', 'external-links-section': 'external-links-section', 'health-check-section': 'health-check-section', 'system-settings-section': 'system-settings-section', diff --git a/application/single_app/static/js/chat/chat-documents.js b/application/single_app/static/js/chat/chat-documents.js index 40be40e6c..40a7dec48 100644 --- a/application/single_app/static/js/chat/chat-documents.js +++ b/application/single_app/static/js/chat/chat-documents.js @@ -89,6 +89,9 @@ const tagsSearchController = initializeFilterableDropdownSearch({ }); const SEARCH_DOCUMENTS_MOBILE_MEDIA_QUERY = '(max-width: 991.98px)'; +const SEARCH_DROPDOWN_VIEWPORT_PADDING = 16; +const SEARCH_FILTER_DESKTOP_MIN_WIDTH = 320; +const SEARCH_FILTER_DESKTOP_MAX_WIDTH = 640; function isSearchDocumentsMobileDrawerViewport() { return typeof window !== 'undefined' && window.matchMedia(SEARCH_DOCUMENTS_MOBILE_MEDIA_QUERY).matches; @@ -194,22 +197,35 @@ function refreshDocumentsAndTags({ source = null, showLoading = true } = {}) { }); } -function getSearchDocumentsDropdownConfig() { +function getSearchDocumentsDropdownConfig({ openUpOnDesktop = false } = {}) { return { boundary: 'viewport', reference: 'toggle', autoClose: 'outside', - popperConfig: { - strategy: 'fixed', - modifiers: [ - { - name: 'preventOverflow', - options: { - boundary: 'viewport', - padding: 10, + popperConfig(defaultConfig) { + const shouldOpenUp = openUpOnDesktop && !isSearchDocumentsMobileDrawerViewport(); + const defaultModifiers = Array.isArray(defaultConfig.modifiers) ? defaultConfig.modifiers : []; + const modifiers = defaultModifiers.filter(modifier => !['preventOverflow', 'flip'].includes(modifier.name)); + + return { + ...defaultConfig, + placement: shouldOpenUp ? 'top-start' : 'bottom-start', + strategy: 'fixed', + modifiers: [ + ...modifiers, + { + name: 'preventOverflow', + options: { + boundary: 'viewport', + padding: 10, + }, }, - }, - ], + { + name: 'flip', + enabled: !shouldOpenUp, + }, + ], + }; }, }; } @@ -220,15 +236,31 @@ function sizeSearchFilterDropdown(buttonEl, menuEl, itemsContainerEl) { } const fieldContainer = buttonEl.closest('.chat-search-panel-field'); - const containerWidth = fieldContainer ? fieldContainer.offsetWidth : buttonEl.offsetWidth || 280; - - menuEl.style.width = `${containerWidth}px`; - menuEl.style.maxWidth = `${containerWidth}px`; + const containerWidth = fieldContainer ? fieldContainer.offsetWidth : buttonEl.offsetWidth || SEARCH_FILTER_DESKTOP_MIN_WIDTH; + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || SEARCH_FILTER_DESKTOP_MAX_WIDTH; + const viewportMaxWidth = Math.max(160, viewportWidth - (SEARCH_DROPDOWN_VIEWPORT_PADDING * 2)); + const isMobileDrawer = isSearchDocumentsMobileDrawerViewport(); + const minWidth = isMobileDrawer + ? Math.min(containerWidth, viewportMaxWidth) + : Math.min(Math.max(containerWidth, SEARCH_FILTER_DESKTOP_MIN_WIDTH), viewportMaxWidth); + const maxWidth = isMobileDrawer + ? minWidth + : Math.min(Math.max(containerWidth, SEARCH_FILTER_DESKTOP_MAX_WIDTH), viewportMaxWidth); + + menuEl.style.width = isMobileDrawer ? `${Math.round(minWidth)}px` : 'max-content'; + menuEl.style.minWidth = `${Math.round(minWidth)}px`; + menuEl.style.maxWidth = `${Math.round(maxWidth)}px`; menuEl.style.zIndex = '1060'; const menuRect = menuEl.getBoundingClientRect(); + const buttonRect = buttonEl.getBoundingClientRect(); const viewportHeight = window.innerHeight; - const maxPossibleHeight = Math.max(180, viewportHeight - menuRect.top - 10); + const popperPlacement = menuEl.getAttribute('data-popper-placement') || ''; + const opensUp = popperPlacement.startsWith('top') && !isMobileDrawer; + const availableHeight = opensUp + ? buttonRect.top - SEARCH_DROPDOWN_VIEWPORT_PADDING + : viewportHeight - menuRect.top - SEARCH_DROPDOWN_VIEWPORT_PADDING; + const maxPossibleHeight = Math.max(opensUp ? 120 : 180, availableHeight); menuEl.style.maxHeight = `${maxPossibleHeight}px`; @@ -246,6 +278,7 @@ function resetSearchFilterDropdownStyles(menuEl, itemsContainerEl) { if (menuEl) { menuEl.style.maxHeight = ''; menuEl.style.maxWidth = ''; + menuEl.style.minWidth = ''; menuEl.style.width = ''; menuEl.style.zIndex = ''; } @@ -263,13 +296,14 @@ function initializeSearchFilterDropdown({ itemsContainerEl, searchInputEl, searchController, + openUpOnDesktop = false, onShown, }) { if (!dropdownEl || !buttonEl || !menuEl) { return; } - new bootstrap.Dropdown(buttonEl, getSearchDocumentsDropdownConfig()); + new bootstrap.Dropdown(buttonEl, getSearchDocumentsDropdownConfig({ openUpOnDesktop })); dropdownEl.addEventListener('show.bs.dropdown', function() { if (searchInputEl) { @@ -283,6 +317,12 @@ function initializeSearchFilterDropdown({ sizeSearchFilterDropdown(buttonEl, menuEl, itemsContainerEl); onShown?.(); + try { + bootstrap.Dropdown.getInstance(buttonEl)?.update(); + } catch (error) { + console.error('Error updating search filter dropdown placement:', error); + } + if (searchInputEl) { setTimeout(() => searchInputEl.focus(), 50); } @@ -1309,27 +1349,7 @@ function initializeDocumentDropdown() { filterDocumentsBySelectedTags(); documentSearchController?.applyFilter(docSearchInput ? docSearchInput.value : ''); - // Size the dropdown to fill its parent container - const parentContainer = docDropdownButton.closest('.flex-grow-1'); - const maxWidth = parentContainer ? parentContainer.offsetWidth : 400; - - docDropdownMenu.style.maxWidth = `${maxWidth}px`; - docDropdownMenu.style.width = `${maxWidth}px`; - - // Ensure dropdown stays within viewport bounds - const menuRect = docDropdownMenu.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - - if (menuRect.bottom > viewportHeight) { - const maxPossibleHeight = viewportHeight - menuRect.top - 10; - docDropdownMenu.style.maxHeight = `${maxPossibleHeight}px`; - - if (docDropdownItems) { - const searchContainer = docDropdownMenu.querySelector('.document-search-container'); - const searchHeight = searchContainer ? searchContainer.offsetHeight : 40; - docDropdownItems.style.maxHeight = `${maxPossibleHeight - searchHeight}px`; - } - } + sizeSearchFilterDropdown(docDropdownButton, docDropdownMenu, docDropdownItems); } /* --------------------------------------------------------------------------- Load Tags for Selected Scope @@ -2014,6 +2034,7 @@ document.addEventListener('DOMContentLoaded', function() { itemsContainerEl: docDropdownItems, searchInputEl: docSearchInput, searchController: documentSearchController, + openUpOnDesktop: true, onShown: initializeDocumentDropdown, }); } diff --git a/application/single_app/static/js/plugin_common.js b/application/single_app/static/js/plugin_common.js index e50278d4d..dba421a09 100644 --- a/application/single_app/static/js/plugin_common.js +++ b/application/single_app/static/js/plugin_common.js @@ -64,7 +64,7 @@ export function escapeHtml(str) { } // Render plugins table (parameterized for tbody selector and button handlers) -export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, onView, ensureTable = true, isAdmin = false}) { +export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, onView, onGovern, onDuplicate, ensureTable = true, isAdmin = false}) { // Optionally ensure the table is present before rendering if (ensureTable) { ensurePluginsTableInRoot(); @@ -75,54 +75,70 @@ export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, on return; } tbody.innerHTML = ''; + + const createActionButton = (className, title, iconClass, handler, pluginName) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = className; + button.dataset.pluginName = pluginName; + button.title = title; + const icon = document.createElement('i'); + icon.className = iconClass; + button.appendChild(icon); + button.addEventListener('click', () => { + if (handler) { + handler(pluginName); + } + }); + return button; + }; + plugins.forEach(plugin => { const tr = document.createElement('tr'); - const safeName = escapeHtml(plugin.name); + const pluginName = plugin.name || ''; const displayName = humanizeName(plugin.display_name || plugin.name); - const safeDisplayName = escapeHtml(displayName); const description = plugin.description || 'No description available'; - const truncatedDesc = escapeHtml(truncateDescription(description, 90)); - let actionButtons = ''; - let globalBadge = plugin.is_global ? ' Global' : ''; - - // View button always shown - let viewButton = ``; - - // Edit/Delete buttons based on context + const truncatedDesc = truncateDescription(description, 90); + + const nameCell = document.createElement('td'); + const nameStrong = document.createElement('strong'); + nameStrong.title = plugin.display_name || plugin.name || ''; + nameStrong.textContent = displayName; + nameCell.appendChild(nameStrong); + if (plugin.is_global) { + nameCell.appendChild(document.createTextNode(' ')); + const globalBadge = document.createElement('span'); + globalBadge.className = 'badge bg-info text-dark'; + globalBadge.textContent = 'Global'; + nameCell.appendChild(globalBadge); + } + + const descriptionCell = document.createElement('td'); + descriptionCell.className = 'text-muted small'; + descriptionCell.title = description; + descriptionCell.textContent = truncatedDesc; + + const actionsCell = document.createElement('td'); + const actionButtons = document.createElement('div'); + actionButtons.className = 'd-flex gap-1'; + actionButtons.appendChild(createActionButton('btn btn-sm btn-outline-info view-plugin-btn me-1', 'View details', 'bi bi-eye', onView, pluginName)); + let editDeleteButtons = ''; if (isAdmin || !plugin.is_global) { - editDeleteButtons = ` - - `; + actionButtons.appendChild(createActionButton('btn btn-sm btn-outline-secondary edit-plugin-btn', 'Edit action', 'bi bi-pencil', onEdit, pluginName)); + if (isAdmin) { + actionButtons.appendChild(createActionButton('btn btn-sm btn-outline-info govern-plugin-btn', 'Govern action', 'bi bi-shield-check', onGovern, pluginName)); + actionButtons.appendChild(createActionButton('btn btn-sm btn-outline-secondary duplicate-plugin-btn', 'Duplicate action', 'bi bi-files', onDuplicate, pluginName)); + } + actionButtons.appendChild(createActionButton('btn btn-sm btn-outline-danger delete-plugin-btn', 'Delete action', 'bi bi-trash', onDelete, pluginName)); } - actionButtons = `
${viewButton}${editDeleteButtons}
`; - tr.innerHTML = ` - ${safeDisplayName}${globalBadge} - ${truncatedDesc} - ${actionButtons} - `; + + actionsCell.appendChild(actionButtons); + tr.appendChild(nameCell); + tr.appendChild(descriptionCell); + tr.appendChild(actionsCell); tbody.appendChild(tr); }); - // Attach event handlers - tbody.querySelectorAll('.edit-plugin-btn').forEach(btn => { - btn.onclick = () => onEdit(btn.getAttribute('data-plugin-name')); - }); - tbody.querySelectorAll('.delete-plugin-btn').forEach(btn => { - btn.onclick = () => onDelete(btn.getAttribute('data-plugin-name')); - }); - tbody.querySelectorAll('.view-plugin-btn').forEach(btn => { - btn.onclick = () => { - if (onView) { - onView(btn.getAttribute('data-plugin-name')); - } - }; - }); } // Render plugins grid (card-based view) diff --git a/application/single_app/static/js/workspace/workspace-documents.js b/application/single_app/static/js/workspace/workspace-documents.js index 1b686b996..683e98c97 100644 --- a/application/single_app/static/js/workspace/workspace-documents.js +++ b/application/single_app/static/js/workspace/workspace-documents.js @@ -29,6 +29,7 @@ const docMetadataModalEl = document.getElementById("docMetadataModal") ? new boo const docMetadataForm = document.getElementById("doc-metadata-form"); const docsSharedOnlyFilter = document.getElementById("docs-shared-only-filter"); const deleteSelectedBtn = document.getElementById("delete-selected-btn"); +const chatSelectedBtn = document.getElementById("chat-selected-btn"); const clearSelectionBtn = document.getElementById("clear-selection-btn"); const documentDeleteModalElement = document.getElementById("documentDeleteModal"); const documentDeleteModal = documentDeleteModalElement ? new bootstrap.Modal(documentDeleteModalElement) : null; @@ -2061,6 +2062,10 @@ document.addEventListener('DOMContentLoaded', function() { if (deleteSelectedBtn) { deleteSelectedBtn.addEventListener('click', window.deleteSelectedDocuments); } + + if (chatSelectedBtn) { + chatSelectedBtn.addEventListener('click', window.chatWithSelected); + } // Clear selection button if (clearSelectionBtn) { diff --git a/application/single_app/templates/_custom_pages_drawer.html b/application/single_app/templates/_custom_pages_drawer.html new file mode 100644 index 000000000..6e932e701 --- /dev/null +++ b/application/single_app/templates/_custom_pages_drawer.html @@ -0,0 +1,48 @@ +{% if session.get('user') and app_settings.enable_custom_pages and custom_pages_nav %} +
+
+
+
{{ app_settings.custom_pages_menu_name or 'Custom Pages' }}
+
{{ custom_pages_nav|length }} page{% if custom_pages_nav|length != 1 %}s{% endif %}
+
+ +
+
+
+ {% for page in custom_pages_nav %} + + + {{ page.label }} + + {% endfor %} +
+
+
+ +{% endif %} \ No newline at end of file diff --git a/application/single_app/templates/_governance_info.html b/application/single_app/templates/_governance_info.html new file mode 100644 index 000000000..e6d6da40e --- /dev/null +++ b/application/single_app/templates/_governance_info.html @@ -0,0 +1,339 @@ + + + + \ No newline at end of file diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index d26cdfb42..4902d0846 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -190,6 +190,56 @@ {% endif %} + + {% if session.get('user') and app_settings.enable_custom_pages and custom_pages_nav %} +
+ {% set custom_page_count = custom_pages_nav|length %} + {% set custom_pages_drawer_threshold = 5 %} + {% set custom_pages_use_drawer = custom_page_count > custom_pages_drawer_threshold %} + {% set custom_pages_should_force_menu = app_settings.custom_pages_force_menu or custom_page_count >= 3 %} + {% if custom_pages_use_drawer %} + + {% elif custom_pages_should_force_menu %} +
+ {{ app_settings.custom_pages_menu_name or 'Custom Pages' }} + {{ custom_page_count }} + +
+ + {% else %} +
+ {{ app_settings.custom_pages_menu_name or 'Custom Pages' }} + {{ custom_page_count }} +
+ + {% endif %} +
+ {% endif %} + {% set support_menu_role_allowed = session.get('user') and session['user'].get('roles') and ('Admin' in session['user']['roles'] or 'User' in session['user']['roles']) %} {% if support_menu_role_allowed and app_settings.enable_support_menu %} @@ -431,6 +481,18 @@ + + + + +