200 lines
10 KiB
Python
200 lines
10 KiB
Python
"""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)
|