Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions application/single_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,23 @@ def _is_idle_timeout_exempt(path):
return True
return any(path.startswith(prefix) for prefix in IDLE_TIMEOUT_EXEMPT_PREFIXES)


def maybe_log_authenticated_browser_request():
"""Record throttled login activity for authenticated browser page requests."""
if request.method != 'GET' or request.path.startswith('/api/'):
return

user_id = session.get('user', {}).get('oid') or session.get('user', {}).get('sub')
if not user_id:
return

maybe_log_authenticated_request_login(
user_id=user_id,
session_state=session,
request_path=request.path,
request_method=request.method
)

@app.before_request
def enforce_idle_session_timeout():
"""
Expand Down Expand Up @@ -646,6 +663,7 @@ def enforce_idle_session_timeout():
if should_refresh_last_activity:
session['last_activity_epoch'] = now_epoch
session.modified = True
maybe_log_authenticated_browser_request()
return None

idle_timeout_minutes, _ = get_idle_timeout_settings(request_settings)
Expand Down Expand Up @@ -698,6 +716,7 @@ def enforce_idle_session_timeout():

session['last_activity_epoch'] = now_epoch
session.modified = True
maybe_log_authenticated_browser_request()
return None

@app.after_request
Expand Down
36 changes: 33 additions & 3 deletions application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.241.022"
VERSION = "0.241.130"

SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')

Expand All @@ -106,9 +106,9 @@
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; "
#"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://code.jquery.com https://stackpath.bootstrapcdn.com; "
"style-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
#"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; "
"img-src 'self' data: https: blob:; "
"font-src 'self'; "
Expand Down Expand Up @@ -309,6 +309,18 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str:
partition_key=PartitionKey(path="/conversation_id")
)

cosmos_personal_workflows_container_name = "personal_workflows"
cosmos_personal_workflows_container = cosmos_database.create_container_if_not_exists(
id=cosmos_personal_workflows_container_name,
partition_key=PartitionKey(path="/user_id")
)

cosmos_personal_workflow_runs_container_name = "personal_workflow_runs"
cosmos_personal_workflow_runs_container = cosmos_database.create_container_if_not_exists(
id=cosmos_personal_workflow_runs_container_name,
partition_key=PartitionKey(path="/user_id")
)

cosmos_group_conversations_container_name = "group_conversations"
cosmos_group_conversations_container = cosmos_database.create_container_if_not_exists(
id=cosmos_group_conversations_container_name,
Expand All @@ -321,6 +333,24 @@ def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str:
partition_key=PartitionKey(path="/conversation_id")
)

cosmos_collaboration_conversations_container_name = "collaboration_conversations"
cosmos_collaboration_conversations_container = cosmos_database.create_container_if_not_exists(
id=cosmos_collaboration_conversations_container_name,
partition_key=PartitionKey(path="/id")
)

cosmos_collaboration_messages_container_name = "collaboration_messages"
cosmos_collaboration_messages_container = cosmos_database.create_container_if_not_exists(
id=cosmos_collaboration_messages_container_name,
partition_key=PartitionKey(path="/conversation_id")
)

cosmos_collaboration_user_state_container_name = "collaboration_user_state"
cosmos_collaboration_user_state_container = cosmos_database.create_container_if_not_exists(
id=cosmos_collaboration_user_state_container_name,
partition_key=PartitionKey(path="/user_id")
)

cosmos_settings_container_name = "settings"
cosmos_settings_container = cosmos_database.create_container_if_not_exists(
id=cosmos_settings_container_name,
Expand Down
103 changes: 96 additions & 7 deletions application/single_app/functions_activity_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

import logging
import time
import uuid
from datetime import datetime
from typing import Optional, Dict, Any
Expand All @@ -13,6 +14,83 @@
from config import cosmos_activity_logs_container


USER_LOGIN_ACTIVITY_SESSION_KEY = 'last_user_login_activity_epoch'
USER_LOGIN_ACTIVITY_MIN_INTERVAL_SECONDS = 15 * 60


def _parse_session_epoch(session_state: Optional[dict], session_key: str) -> Optional[int]:
"""Safely parse an epoch value stored in session state."""
if session_state is None:
return None

raw_epoch = session_state.get(session_key)
if raw_epoch is None:
return None

try:
return int(float(raw_epoch))
except (TypeError, ValueError):
return None


def record_user_login_session_activity(
session_state: Optional[dict],
now_epoch: Optional[int] = None
) -> Optional[int]:
"""Persist the last time login activity was recorded for the current session."""
if session_state is None:
return None

resolved_epoch = int(now_epoch if now_epoch is not None else time.time())
session_state[USER_LOGIN_ACTIVITY_SESSION_KEY] = resolved_epoch

if hasattr(session_state, 'modified'):
session_state.modified = True

return resolved_epoch


def maybe_log_authenticated_request_login(
user_id: str,
session_state: Optional[dict],
request_path: str,
request_method: str = 'GET',
now_epoch: Optional[int] = None,
login_method: str = 'authenticated_request',
min_interval_seconds: int = USER_LOGIN_ACTIVITY_MIN_INTERVAL_SECONDS
) -> bool:
"""
Log a throttled login-style activity for authenticated browser requests.

This captures passive SSO/session-based access that never re-enters the
explicit OAuth callback, while preventing per-request log spam.
"""
if not user_id or session_state is None:
return False

normalized_method = (request_method or '').upper()
if normalized_method != 'GET':
return False

resolved_epoch = int(now_epoch if now_epoch is not None else time.time())
last_logged_epoch = _parse_session_epoch(session_state, USER_LOGIN_ACTIVITY_SESSION_KEY)
if last_logged_epoch is not None and (resolved_epoch - last_logged_epoch) < min_interval_seconds:
return False

log_user_login(
user_id,
login_method,
activity_details={
'auth_signal': 'authenticated_request',
'request_path': request_path,
'request_method': normalized_method,
'is_interactive_login': False
}
)
record_user_login_session_activity(session_state, resolved_epoch)
return True


def _get_email_domain(email: str) -> str:
"""Return only the email domain for low-sensitivity audit metadata."""
normalized_email = (email or '').strip()
Expand Down Expand Up @@ -1113,7 +1191,8 @@ def log_conversation_archival(

def log_user_login(
user_id: str,
login_method: str = 'azure_ad'
login_method: str = 'azure_ad',
activity_details: Optional[Dict[str, Any]] = None
) -> None:
"""
Log user login activity to the activity_logs container.
Expand All @@ -1125,19 +1204,29 @@ def log_user_login(

try:
# Create login activity record
import uuid
login_details = {
'login_method': login_method,
'success': True
}
if activity_details:
login_details.update({
key: value for key, value in activity_details.items()
if value is not None
})

login_activity = {
'id': str(uuid.uuid4()),
'user_id': user_id,
'activity_type': 'user_login',
'login_method': login_method,
'timestamp': datetime.utcnow().isoformat(),
'created_at': datetime.utcnow().isoformat(),
'details': {
'login_method': login_method,
'success': True
}
'details': login_details
}

for key, value in login_details.items():
if key not in {'login_method', 'success'}:
login_activity[key] = value

# Save to activity_logs container
cosmos_activity_logs_container.create_item(body=login_activity)
Expand All @@ -1148,7 +1237,7 @@ def log_user_login(
extra=login_activity,
level=logging.INFO
)
debug_print(f"✅ User login activity logged for user {user_id}")
debug_print(f"✅ User login activity logged for user {user_id} via {login_method}")

except Exception as e:
# Log error but don't break the login flow
Expand Down
3 changes: 2 additions & 1 deletion application/single_app/route_frontend_authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from unittest import result
from config import *
from functions_activity_logging import log_user_login, record_user_login_session_activity
from functions_authentication import _build_msal_app, _load_cache, _save_cache, clear_requested_oauth_scopes, get_requested_oauth_scopes
from functions_debug import debug_print
from swagger_wrapper import swagger_route, get_auth_security
Expand Down Expand Up @@ -133,10 +134,10 @@ def authorized():

# Log the login activity
try:
from functions_activity_logging import log_user_login
user_id = session['user'].get('oid') or session['user'].get('sub')
if user_id:
log_user_login(user_id, 'azure_ad')
record_user_login_session_activity(session)
except Exception as e:
debug_print(f"Could not log login activity: {e}")

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Authenticated Request Login Activity Fix

Fixed/Implemented in version: **0.241.130**

## Overview

This fix closes the gap between explicit OAuth callback logins and real authenticated usage. Previously, activity tracking only recorded `user_login` when the `/getAToken` callback completed. Users who arrived with an already-authenticated session or seamless SSO reuse could browse the app without creating the login-style activity that the Control Center and profile dashboards rely on.

Version implemented:
`config.py` now reports `VERSION = "0.241.130"` for this fix.

## Issue Description

- Login analytics depended on an explicit callback event rather than the broader fact that the user had an authenticated browser session.
- Passive SSO or session reuse could authenticate the user successfully but leave login metrics empty or understated.
- Simply logging every authenticated request would have created noisy over-counting, especially for API-heavy pages.

## Root Cause

- The application treated `user_login` as a one-time OAuth callback event instead of a reusable authenticated-session signal.
- The authenticated request pipeline had no throttled activity writer for browser page access.
- The explicit callback flow had no session marker to prevent a duplicate login record on the immediate redirect to the landing page.

## Technical Changes

### Throttled Authenticated Request Tracking

Changes implemented:

- Added a shared authenticated-request helper in `functions_activity_logging.py` that records a `user_login` activity with `login_method = authenticated_request`.
- Added a session-scoped throttle window so repeated authenticated page loads do not emit a record on every request.
- Captured request metadata such as `request_path`, `request_method`, and `auth_signal` for later diagnostics.

Files involved:

- `application/single_app/functions_activity_logging.py`
- `application/single_app/app.py`

Behavioral outcome:

Authenticated page visits now show up in the existing login analytics even when the user did not intentionally click a login button during that session.

### OAuth Callback Deduplication

Changes implemented:

- Marked the session immediately after the explicit `/getAToken` callback logs `user_login`.
- Reused that session marker to suppress an immediate second `user_login` on the redirect to `/`.

Files involved:

- `application/single_app/route_frontend_authentication.py`

Behavioral outcome:

Explicit callback logins remain single-counted while passive authenticated navigation still becomes visible later in the session.

## Files Modified

- `application/single_app/functions_activity_logging.py`
- `application/single_app/app.py`
- `application/single_app/route_frontend_authentication.py`
- `application/single_app/config.py`
- `functional_tests/test_authenticated_request_login_activity.py`

## Validation

Testing approach:

- Added a focused functional regression test that loads `functions_activity_logging.py` with stubbed dependencies so the new throttle and dedup behavior can be exercised without a live Cosmos dependency.
- Ran targeted compile checks on the edited Python files.

Validation performed for this implementation:

- `python -m py_compile application/single_app/functions_activity_logging.py`
- `python -m py_compile application/single_app/app.py`
- `python -m py_compile application/single_app/route_frontend_authentication.py`
- `python -m py_compile functional_tests/test_authenticated_request_login_activity.py`
- `python functional_tests/test_authenticated_request_login_activity.py`

## Before And After

Before:

- Login metrics depended almost entirely on explicit OAuth callback completions.
- Passive SSO/session-reuse visits could authenticate successfully without contributing to login analytics.
- A naive request-level fix risked over-counting every authenticated browser/API request.

After:

- Authenticated browser page requests can emit a throttled `user_login` record when no recent login activity has been recorded in the current session.
- Explicit OAuth callback logins still record normally and are not immediately double-counted on redirect.
- Existing dashboards that already query `activity_type = 'user_login'` now see a more representative picture of real authenticated use.

## User Experience Impact

- Admins get more representative login activity in the Control Center and profile trends for users who rely on seamless SSO.
- Users do not see any UI change.
- Login metrics should remain substantially less noisy than per-request tracking because the authenticated-request signal is throttled and limited to browser GET requests.
Loading
Loading