""" Permissions Log Viewer """ import pandas as pd import streamlit as st import data_processing import database_module import ui_components import visualization_module # Page Configuration st.set_page_config( page_title='Permissions Log Viewer', page_icon='đ', layout='wide', initial_sidebar_state='expanded', ) def initialize_session_state(): """Initialize session state variables""" if 'query_executed' not in st.session_state: st.session_state.query_executed = False if 'audit_data' not in st.session_state: st.session_state.audit_data = None if 'identity_id' not in st.session_state: st.session_state.identity_id = '' if 'filter_key' not in st.session_state: st.session_state.filter_key = 0 def main(): """Main application logic""" initialize_session_state() # Header ui_components.render_header( title='đ Permissions Log Viewer', subtitle='View user Profile (Neo4j) based access changes by analyzing CDC events', ) # About Section with st.expander('âšī¸ About This Application'): st.markdown(""" For more information about this application, see the [GitHub repository](https://github.com/theorchard/collab/tree/master/ratoui/permissions-log-streamlit). """) # Input Section col1, col2 = st.columns([4, 1]) with col1: identity_input = st.text_input( 'Identity (UUID or Email)', value=st.session_state.identity_id, placeholder='e.g., 00009283-735e-4956-99e4-21d6d1ac7efd or user@example.com', ) with col2: # Add spacing to align button with input box st.markdown('
', unsafe_allow_html=True) query_button = st.button('Lookup ', type='primary', use_container_width=False) # Execute Query if query_button: if not identity_input or identity_input.strip() == '': st.error('Please enter a valid Identity UUID or Email') return # Detect if input is email or UUID and resolve accordingly import re uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' identity_id = None if re.match(uuid_pattern, identity_input.lower()): # Input is a UUID identity_id = identity_input elif re.match(email_pattern, identity_input): # Input is an email - resolve to UUID with st.spinner(f'Looking up identity for {identity_input}...'): identity_id = database_module.resolve_email_to_uuid(identity_input) if not identity_id: st.error(f'No identity found for email: {identity_input}') return st.success(f'â Found identity: {identity_id}') else: st.error( 'Invalid format. Please enter a valid UUID ' '(e.g., 00009283-735e-4956-99e4-21d6d1ac7efd) or email address' ) return # Clear previous query results before starting new query st.session_state.query_executed = False st.session_state.audit_data = None if 'query_step_results' in st.session_state: st.session_state.query_step_results = None if 'selected_event_row' in st.session_state: st.session_state.selected_event_row = None # Increment filter_key to reset all filters st.session_state.filter_key += 1 st.session_state.identity_id = identity_id # Query the database using decomposed approach # Note: The execution steps are displayed by the query function and remain visible try: # Query all events (no date filtering - filter happens in UI) df = database_module.query_audit_log(identity_id) if df is None or df.empty: st.warning(f'No audit log entries found for Identity ID: {identity_id}') st.session_state.query_executed = False st.session_state.audit_data = None return # Process the data df = data_processing.process_audit_data(df) # Hydrate tenants (must happen AFTER process_audit_data creates tenant_type column) df = database_module.hydrate_and_store_tenants(df) st.session_state.audit_data = df st.session_state.query_executed = True st.success(f'â Found {len(df)} audit log entries for Identity ID: {identity_id}') except Exception as e: st.error(f'Error querying audit log: {str(e)}') st.session_state.query_executed = False st.session_state.audit_data = None return # Display Results if st.session_state.query_executed and st.session_state.audit_data is not None: df = st.session_state.audit_data # Display link to user in Settings settings_url = f'https://settings.theorchard.com/users/{st.session_state.identity_id}' settings_icon = ( 'https://img.notionusercontent.com/ext/https%3A%2F%2Fs3-us-west-2.amazonaws.com' '%2Fpublic.notion-static.com%2Ff1d56493-70d0-401e-b958-79a28c552ac2%2F' 'svgexport-1_(1).svg/size/?exp=1760241138&sig=dMoxWougVilHgVluugjUe7Lw4Wroz-8i' '7L4huGKUxm0&id=27497177-520f-806c-a5d0-007ac0447156&table=custom_emoji&' 'userId=877014fb-0b97-42df-b124-d9eed83cb536' ) st.markdown( f' **View User in Settings:** ' f'[{settings_url}]({settings_url})', unsafe_allow_html=True, ) st.markdown('') # Spacing st.divider() # Summary Metrics st.subheader('đ Summary') # Display profiles and access counts if available if 'query_step_results' in st.session_state and st.session_state.query_step_results: step_results = st.session_state.query_step_results # Profiles table (full width) if 'profiles_df' in step_results and not step_results['profiles_df'].empty: profiles_df = step_results['profiles_df'] st.markdown(f'**Profiles Found ({len(profiles_df)}):**') # Column slicing creates a view, no need for .copy() display_profiles = profiles_df[['profile_type', 'profile_id', 'profile_uuid']] display_profiles['profile_id'] = display_profiles['profile_id'].astype(int) st.dataframe( display_profiles, use_container_width=True, hide_index=True, column_config={ 'profile_type': st.column_config.TextColumn('Type', width='small'), 'profile_id': st.column_config.NumberColumn('ID', width='small'), 'profile_uuid': st.column_config.TextColumn('UUID', width='large'), }, ) else: st.markdown('**Profiles Found (0):**') st.info('No profiles found') st.markdown('') # Spacing # Events by access type table (full width) st.markdown('**Events by Access Type:**') if 'access_counts' in step_results: access_counts = step_results['access_counts'] # Create a nice formatted table counts_df = pd.DataFrame( [{'Access Type': k, 'Count': v} for k, v in access_counts.items()] ) st.dataframe( counts_df, use_container_width=True, hide_index=True, column_config={ 'Access Type': st.column_config.TextColumn('Access Type', width='large'), 'Count': st.column_config.NumberColumn('Count', width='small'), }, ) st.caption(f'**Total Events:** {sum(access_counts.values())}') else: st.info('No access counts available') # Display missing profiles if any if 'missing_profiles' in step_results and step_results['missing_profiles']: st.markdown('') # Spacing st.markdown('**â ī¸ Profiles Not Found in FACT.PROD.PROFILE:**') missing_profiles = step_results['missing_profiles'] # Create a dataframe for display missing_df = pd.DataFrame( { 'Profile UUID': missing_profiles, 'Status': ['Not Found'] * len(missing_profiles), } ) st.dataframe( missing_df, use_container_width=True, hide_index=True, column_config={ 'Profile UUID': st.column_config.TextColumn('Profile UUID', width='large'), 'Status': st.column_config.TextColumn('Status', width='small'), }, ) st.caption( f'âšī¸ These {len(missing_profiles)} profile(s) exist in CDC but not in ' 'FACT.PROD.PROFILE' ) # Display tenant type summary st.markdown('') # Spacing st.markdown('**Tenants by Type:**') if not df.empty and 'tenant_type' in df.columns: # Count unique tenants by type tenant_summary = ( df.groupby('tenant_type')['tenant_id'] .nunique() .reset_index() .rename(columns={'tenant_id': 'Unique Count'}) ) tenant_summary.columns = ['Tenant Type', 'Unique Count'] # Sort by count descending tenant_summary = tenant_summary.sort_values('Unique Count', ascending=False) st.dataframe( tenant_summary, use_container_width=True, hide_index=True, column_config={ 'Tenant Type': st.column_config.TextColumn('Tenant Type', width='medium'), 'Unique Count': st.column_config.NumberColumn( 'Unique Tenants', width='small' ), }, ) st.caption(f'**Total Unique Tenants:** {tenant_summary["Unique Count"].sum()}') else: st.info('No tenant data available') st.divider() # Get tenant metadata for filters tenant_metadata = {} if 'query_step_results' in st.session_state and st.session_state.query_step_results: tenant_metadata = st.session_state.query_step_results.get('tenant_metadata', {}) # Filters in Sidebar with st.sidebar: st.header('đ§ Filters') filtered_df, selected_tenant = ui_components.render_filters( df, st.session_state.filter_key, tenant_metadata ) st.divider() # Export Section st.header('đĨ Export Data') csv_data = data_processing.export_to_csv(filtered_df) st.download_button( label='Download Filtered Results as CSV', data=csv_data, file_name=f'audit_log_{st.session_state.identity_id}_{pd.Timestamp.now().strftime("%Y%m%d_%H%M%S")}.csv', mime='text/csv', use_container_width=True, ) st.divider() st.caption(f'Total Events: {len(df)}') st.caption(f'Filtered Events: {len(filtered_df)}') st.divider() # Event Details Section in Sidebar st.header('đ Event Details') st.markdown('_Select a row from the table to view CDC event details_') df_indexed = filtered_df.reset_index(drop=True) selected_row = st.number_input( 'Row #', min_value=0, max_value=max(0, len(df_indexed) - 1), value=st.session_state.get('selected_event_row', 0) if len(df_indexed) > 0 else 0, step=1, key=f'event_row_selector_{st.session_state.filter_key}', ) if st.button( 'View Details', type='primary', use_container_width=True, key=f'view_details_btn_{st.session_state.filter_key}', ): st.session_state['selected_event_row'] = selected_row st.rerun() # Display event details IN THE SIDEBAR if a row is selected if ( 'selected_event_row' in st.session_state and st.session_state['selected_event_row'] is not None ): row_idx = st.session_state['selected_event_row'] if 0 <= row_idx < len(df_indexed): st.markdown(f'**Row {row_idx} Details:**') event = df_indexed.iloc[row_idx] ui_components.render_event_details(event) else: st.warning(f'Row {row_idx} is out of range') # Data Table st.subheader('đ CDC Events') ui_components.render_data_table(filtered_df) # Combined Visualizations Section (lazy loading - only renders on expand) st.divider() with st.expander('đ Visualizations & Charts', expanded=False): # Render tenant timeline if a specific tenant is selected if selected_tenant: tenant_type, tenant_id = selected_tenant # Escape asterisks in tenant_id for display tenant_id_display = str(tenant_id).replace('*', r'\*') # Count events for this tenant (from original unfiltered df) event_count = df[ (df['tenant_type'] == tenant_type) & (df['tenant_id'].astype(str) == str(tenant_id)) ].shape[0] st.markdown('') # Spacing st.subheader(f'đ Timeline for {tenant_type} ID {tenant_id_display}') st.caption(f'{event_count} total events for this tenant') # Render timeline chart visualization_module.render_tenant_timeline_chart( df, tenant_type, tenant_id, filtered_df ) st.divider() # General Visualizations st.subheader('đ Summary Charts') visualization_module.render_visualizations(filtered_df) if __name__ == '__main__': main()