"""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)