"""Shared case confidentiality policy for HTML, APIs, feeds and work queues.""" from contextvars import ContextVar import json import re from fastapi import HTTPException from starlette.responses import JSONResponse from psycopg2.errors import DeadlockDetected,SerializationFailure from app.core.database import execute_query, execute_query_single _scope = ContextVar('case_visibility_scope', default=None) _DROP = object() def hidden_case_ids(user): if user.get('is_superadmin'): return set() current = _scope.get() if current is not None and current[0] == user.get('id'): return current[1] if 'permissions' in user: readable=bool(set(user.get('permissions') or []) & {'cases.view','tickets.view'}) else: row=execute_query_single('''SELECT EXISTS(SELECT 1 FROM users WHERE user_id=%s AND is_superadmin) OR EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=%s AND p.code IN ('cases.view','tickets.view')) AS allowed''', (user.get('id'),user.get('id'))) readable=bool(row and row['allowed']) if not readable: return {r['id'] for r in execute_query('SELECT id FROM sag_sager') or []} exists = execute_query_single("SELECT to_regclass('sag_case_visibility') IS NOT NULL AS present") if not exists or not exists['present']: return set() return {r['sag_id'] for r in execute_query('''SELECT v.sag_id FROM sag_case_visibility v WHERE v.restricted AND NOT EXISTS(SELECT 1 FROM sag_case_visibility_users a WHERE a.sag_id=v.sag_id AND a.user_id=%s) AND NOT EXISTS(SELECT 1 FROM sag_case_visibility_groups a JOIN user_groups ug ON ug.group_id=a.group_id WHERE a.sag_id=v.sag_id AND ug.user_id=%s)''',(user.get('id'),user.get('id'))) or []} def require_case(user,sag_id): if sag_id in hidden_case_ids(user): # 404 avoids confirming whether a confidential case exists. raise HTTPException(404,'Sagen findes ikke') def notification_recipients(sag_id,user_ids): exists=execute_query_single("SELECT to_regclass('sag_case_visibility') IS NOT NULL AS present") if not exists or not exists['present']: return user_ids,False users=execute_query('''SELECT u.user_id FROM users u WHERE u.user_id=ANY(%s) AND u.is_active AND (u.is_superadmin OR EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=u.user_id AND p.code IN ('cases.view','tickets.view')))''',(user_ids,)) or [] user_ids=[r['user_id'] for r in users] private=execute_query_single('SELECT restricted FROM sag_case_visibility WHERE sag_id=%s',(sag_id,)) if not private or not private['restricted']: return user_ids,False allowed=execute_query('''SELECT u.user_id FROM users u WHERE u.user_id=ANY(%s) AND u.is_active AND (u.is_superadmin OR EXISTS(SELECT 1 FROM sag_case_visibility_users a WHERE a.sag_id=%s AND a.user_id=u.user_id) OR EXISTS(SELECT 1 FROM sag_case_visibility_groups a JOIN user_groups ug ON ug.group_id=a.group_id WHERE a.sag_id=%s AND ug.user_id=u.user_id))''',(user_ids,sag_id,sag_id)) or [] return [r['user_id'] for r in allowed],True def linked_ids(value,case_list=False): result = set() if not isinstance(value,dict): return result for key in ('sag_id','hub_case_id','kilde_sag_id','målsag_id','parent_sag_id'): if isinstance(value.get(key),int): result.add(value[key]) if value.get('reference_type')=='case' and isinstance(value.get('reference_id'),int): result.add(value['reference_id']) if 'titel' in value and isinstance(value.get('case_id'),int): result.add(value['case_id']) elif (case_list or 'titel' in value or value.get('entity_type')=='case') and isinstance(value.get('id'),int): result.add(value['id']) if value.get('type')=='relation' and isinstance(value.get('case_id'),int): result.add(value['case_id']) for key in ('url','action','href','download_url','link','case_url','action_url','context_path'): if isinstance(value.get(key),str): result.update(int(s) for s in re.findall(r'/sag/(\d+)(?:/|\?|$)',value[key])) if isinstance(value.get('extendedProps'),dict): result.update(linked_ids(value['extendedProps'])) for key in ('case','linked_case','parent_case'): if isinstance(value.get(key),dict): result.update(linked_ids(value[key])) return result def filter_data(value,hidden,context=''): """Drop confidential records; calendar reservations retain only busy intervals.""" if not hidden: return value if isinstance(value,list): result=[] for item in value: filtered=filter_data(item,hidden,context) if filtered is not _DROP: result.append(filtered) return result if not isinstance(value,dict): return value case_list='cases' in context or context in {'/api/v1/sag','/api/v1/planner/cases','urgent','unassigned','today_tasks'} if linked_ids(value,case_list) & hidden: start=value.get('starts_at') or value.get('start') end=value.get('ends_at') or value.get('end') if start and end: safe={k:value[k] for k in ('starts_at','ends_at','start','end','user_id') if k in value} safe.update(title='Optaget',case_title='Optaget',customer_name='',private=True,locked=True,editable=False,conflict_status='none') safe['id']='busy-'+str(value.get('id','reservation')) return safe return _DROP result={} for key,item in value.items(): filtered=filter_data(item,hidden,key if key not in {'items','list'} else context) if filtered is not _DROP: result[key]=filtered return result def requested_case_ids(value): result=set() if isinstance(value,list): for item in value: result.update(requested_case_ids(item)) elif isinstance(value,dict): if value.get('type')=='relation': raw=value.get('case_id') or value.get('caseId') or value.get('target_id') try: result.add(int(str(raw).lstrip('#'))) except (ValueError,TypeError): pass for key,item in value.items(): if key in {'sag_id','hub_case_id','kilde_sag_id','målsag_id'} and isinstance(item,int): result.add(item) elif key=='sag_ids' and isinstance(item,list): result.update(i for i in item if isinstance(i,int)) elif isinstance(item,(dict,list)): result.update(requested_case_ids(item)) return result def scrub_absence_time(value,user_id,can_see_reason): if isinstance(value,list): return [scrub_absence_time(item,user_id,can_see_reason) for item in value] if not isinstance(value,dict): return value result={k:scrub_absence_time(v,user_id,can_see_reason) for k,v in value.items()} owner=value.get('medarbejder_id',value.get('user_id')) if value.get('planner_absence_id') and owner!=user_id and not can_see_reason: for key in ('planner_activity_code','planner_absence_id'): if key in result: result[key]=None if 'description' in result: result['description']='Fravær' return result async def protect_request(request,call_next,user): hidden=hidden_case_ids(user) token=_scope.set((user.get('id'),hidden)) try: request.state.case_superadmin=bool(user.get('is_superadmin')) refs={int(s) for s in re.findall(r'/(?:sag|planner/cases)/(\d+)(?:/|$)',request.url.path)} for key in ('sag_id','kilde_sag_id','målsag_id'): raw=request.query_params.get(key) if raw and raw.isdigit(): refs.add(int(raw)) if hidden and request.method in {'POST','PUT','PATCH','DELETE'} and 'application/json' in request.headers.get('content-type',''): try: refs.update(requested_case_ids(await request.json())) except (ValueError,UnicodeError): pass # Normal endpoint validation handles malformed JSON. if refs & hidden: return JSONResponse({'detail':'Sagen findes ikke'},status_code=404) try: response=await call_next(request) except (DeadlockDetected,SerializationFailure): if request.url.path.startswith('/api/v1/planner'): return JSONResponse({'detail':'Planlægningen blev ændret samtidig. Genindlæs og prøv igen.'},status_code=409) raise check_absence=request.url.path.startswith('/api/v1/timetracking') if (hidden or check_absence) and request.url.path.startswith('/api/') and 'application/json' in response.headers.get('content-type',''): body=b''.join([chunk async for chunk in response.body_iterator]) data=json.loads(body) filtered=filter_data(data,hidden,request.url.path.rstrip('/')) if check_absence and b'planner_absence_id' in body: allowed=bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.absence_reason'}) if not allowed and 'permissions' not in user: row=execute_query_single('''SELECT EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=%s AND p.code IN ('planner.admin','planner.absence_reason')) AS allowed''',(user.get('id'),)) allowed=bool(row and row['allowed']) filtered=scrub_absence_time(filtered,user.get('id'),allowed) result=JSONResponse(None if filtered is _DROP else filtered,status_code=response.status_code,background=response.background) result.raw_headers += [(k,v) for k,v in response.raw_headers if k.lower() not in {b'content-length',b'content-type'}] return result return response finally: _scope.reset(token)