-- MINI HL7 RIM for purpose of review row level security -- no healthcare datatypes -- very limited amount of classes DROP SCHEMA IF EXISTS hl7 CASCADE; DROP ROLE IF EXISTS henry; DROP ROLE IF EXISTS sigmund; DROP ROLE IF EXISTS pete; /* The users that will access the application */ CREATE ROLE henry; CREATE ROLE sigmund; CREATE ROLE pete; CREATE SCHEMA hl7; GRANT USAGE ON SCHEMA hl7 TO public; SET search_path = hl7; DROP SEQUENCE IF EXISTS seq CASCADE; DROP TABLE IF EXISTS entity CASCADE; DROP TABLE IF EXISTS role CASCADE; DROP TABLE IF EXISTS act CASCADE; DROP TABLE IF EXISTS participation CASCADE; DROP TYPE IF EXISTS entityclasscode; DROP TYPE IF EXISTS organizationindustrycode; DROP TYPE IF EXISTS roleclasscode; DROP TYPE IF EXISTS patientvipcode; DROP TYPE IF EXISTS actclasscode; DROP TYPE IF EXISTS actmoodcode; DROP TYPE IF EXISTS confidentialitycode; DROP TYPE IF EXISTS participationtype; CREATE SEQUENCE seq; /* In this mini-example, each inheritance child has exactly one corresponding class code, for instance 'organization' has the associated classcode 'org'. In the full RIM, there is a hierarchy in classcodes, where for instance 'org' is specialized into 'state', 'pub' and 'nat', so the classcode gives more detailed information about what kind of class the row describes, than can be inferred from the inheritance child table alone. */ CREATE TYPE entityclasscode AS ENUM ( 'psn', -- person 'org' -- organization ); /* The real NAICS codesystem uses numbers. For this example, mnemonics are used instead. */ CREATE TYPE organizationindustrycode AS ENUM ( 'fam', -- family planning center 'hos', -- hospital 'psah', -- psychiatric and substance abuse hospitals 'omhs' -- offices of mental health specialists ); CREATE TYPE roleclasscode AS ENUM ( 'pat', -- person 'emp' -- employee ); CREATE TYPE patientvipcode AS ENUM ( 'bm', -- board member 'vip', -- very important person 'for' -- foreign dignitary ); CREATE TYPE actclasscode AS ENUM ( 'obs', -- observation 'sbadm', -- substance administration 'pcpr', -- care provision 'enc' -- encounter ); CREATE TYPE actmoodcode AS ENUM ( 'def', -- definition 'evn', -- event 'gol', -- goal 'rqo', -- request or order 'apt' -- appointment ); CREATE DOMAIN confidentialitycode AS text CHECK ( ARRAY[VALUE] <@ ARRAY[ 'c', -- celebrity 's', -- sensitive 't', -- taboo 'eth', -- substance abuse related 'hiv', -- hiv related 'psy', -- psychiatry relate 'sdv', -- sexual and domestic violence related 'l', -- low 'n', -- normal 'r', -- restricted 'v' -- very restricted ] ); /* work around cannot create array over a domain */ CREATE DOMAIN _confidentialitycode AS text[] CHECK ( VALUE <@ ARRAY[ 'c', -- celebrity 's', -- sensitive 't', -- taboo 'eth', -- substance abuse related 'hiv', -- hiv related 'psy', -- psychiatry relate 'sdv', -- sexual and domestic violence related 'l', -- low 'n', -- normal 'r', -- restricted 'v' -- very restricted ] ); CREATE TYPE participationtype AS ENUM ( 'prf', -- performer 'rct' -- record target (patient about which the record is about) ); CREATE TABLE entity ( id int PRIMARY KEY DEFAULT nextval('seq'), classcode entityclasscode NOT NULL, name text); CREATE TABLE person (birthtime timestamp) INHERITS (entity); CREATE TABLE organization (industrycode organizationindustrycode) INHERITS (entity); CREATE TABLE role ( id int PRIMARY KEY DEFAULT nextval('seq'), classcode roleclasscode NOT NULL, player int NOT NULL, -- references entity(id) scoper int, -- references entity(id) confidentialitycode _confidentialitycode ); CREATE TABLE patient (vipcode patientvipcode) INHERITS (role); CREATE TABLE employee (jobcode text, pgname text) INHERITS (role); CREATE TABLE act ( id int PRIMARY KEY DEFAULT nextval('seq'), classcode actclasscode NOT NULL, moodcode actmoodcode NOT NULL, code text, confidentialitycode _confidentialitycode, effectivetime tsrange); CREATE TABLE observation (value text) INHERITS (act); CREATE TABLE substanceadministration (dosequantity numeric) INHERITS (act); CREATE TABLE participation ( id int PRIMARY KEY DEFAULT nextval('seq'), act int, -- references act(id) role int, -- references role(id) typecode participationtype); DO $$ DECLARE psn1 int; org1 int; pat1 int; act1 int; psn2 int; emp1 int; psn3 int; pat2 int; org2 int; obs1 int; obs2 int; pat3 int; act2 int; psn4 int; emp2 int; BEGIN /* Example adapted from Consolidated CDA CCD 1.xml */ /* Isabella Jones is a celebrity. She has birthtime 19750501 and is patient at provider organization Community Health and Hospitals */ EXECUTE 'INSERT INTO person (classcode, name, birthtime) VALUES (''psn'', ''Isabella Jones'', ''19750501'') RETURNING id' INTO psn1; EXECUTE 'INSERT INTO organization (classcode, name, industrycode) VALUES (''org'', ''Community Health and Hospitals'', ''hos'') RETURNING id' INTO org1; EXECUTE 'INSERT INTO patient (classcode, player, scoper, vipcode) VALUES (''pat'', $1, $2, ''vip'') RETURNING id' USING psn1, org1 INTO pat1; /* Isabella Jones has had a Colonoscopy treatment. Since she is a celebrity all information concerning this treatment is classified as sensitive. */ EXECUTE 'INSERT INTO act (classcode, moodcode, code, confidentialitycode, effectivetime) VALUES (''pcpr'', ''evn'', ''73761001|Colonoscopy'', ''{c,s}'', ''[20120908, 20120915)'') RETURNING id' INTO act1; EXECUTE 'INSERT INTO participation (act, role, typecode) VALUES ($1, $2, ''rct'') RETURNING id' USING act1, pat1; /* Dr. Henry Seven, working at Community Health and Hospitals, was the gastroenterologist who performed the procedure above. */ EXECUTE 'INSERT INTO person (classcode, name) VALUES (''psn'', ''Dr. Henry Seven'') RETURNING id' INTO psn2; EXECUTE 'INSERT INTO employee (classcode, player, scoper, jobcode, pgname) VALUES (''emp'', $1, $2, ''207RG0100X|Gastroenterologist'', ''henry'') RETURNING id' USING psn2, org1 INTO emp1; EXECUTE 'INSERT INTO participation (act, role, typecode) VALUES ($1, $2, ''prf'') RETURNING id' USING act1, emp1; /* Another patient John Doe has had his blood pressure observed in Community Health and Hospitals. */ EXECUTE 'INSERT INTO person (classcode, name, birthtime) VALUES (''psn'', ''John Doe'', ''19630401'') RETURNING id' INTO psn3; EXECUTE 'INSERT INTO patient (classcode, player, scoper, vipcode) VALUES (''pat'', $1, $2, NULL) RETURNING id' USING psn3, org1 INTO pat2; EXECUTE 'INSERT INTO observation (classcode, moodcode, code, confidentialitycode, effectivetime, value) VALUES (''obs'', ''evn'', ''271649006|Systolic blood pressure'', ''{n}'', ''[20090227 130000, 20090227 130000]'', 130) RETURNING id' INTO obs1; /* Just for the sake of testing, let's make the diastolic observation sensitive */ EXECUTE 'INSERT INTO observation (classcode, moodcode, code, confidentialitycode, effectivetime, value) VALUES (''obs'', ''evn'', ''271650006|Diastolic blood pressure'', ''{s}'', ''[20090227 130000, 20090227 130000]'', 90) RETURNING id' INTO obs2; EXECUTE 'INSERT INTO participation (act, role, typecode) VALUES ($1, $2, ''rct'') RETURNING id' USING obs1, pat2; EXECUTE 'INSERT INTO participation (act, role, typecode) VALUES ($1, $2, ''rct'') RETURNING id' USING obs2, pat2; /* John Doe has an appointment with the Community Mental Health Clinic. Since this information concerns mental health, the fact that he is patient and that he has an appointment at such a clinic is considered restricted. */ EXECUTE 'INSERT INTO organization (classcode, name, industrycode) VALUES (''org'', ''Community Mental Health Clinic'', ''hos'') RETURNING id' INTO org2; EXECUTE 'INSERT INTO patient (classcode, player, scoper, confidentialitycode) VALUES (''pat'', $1, $2, ''{r}'') RETURNING id' USING psn3, org2 INTO pat3; EXECUTE 'INSERT INTO act (classcode, moodcode, code, confidentialitycode, effectivetime) VALUES (''enc'', ''apt'', ''intake'', ''{s, r}'', ''[20140301, 20140301]'') RETURNING id' INTO act2; EXECUTE 'INSERT INTO participation (act, role, typecode) VALUES ($1, $2, ''rct'') RETURNING id' USING act2, pat3; /* The appointment above is with Dr. Sigmund. */ EXECUTE 'INSERT INTO person (classcode, name) VALUES (''psn'', ''Dr. Sigmund'') RETURNING id' INTO psn4; EXECUTE 'INSERT INTO employee (classcode, player, scoper, jobcode, pgname) VALUES (''emp'', $1, $2, ''Psychiatrist'',''sigmund'') RETURNING id' USING psn4, org2 INTO emp2; EXECUTE 'INSERT INTO participation (act, role, typecode) VALUES ($1, $2, ''prf'') RETURNING id' USING act2, emp2; END; $$ LANGUAGE plpgsql; /* Superuser can select all */ \echo ====== ENTITY HIERARCHY ======== SELECT * FROM entity; \echo ORGANIZATION SELECT * FROM organization; \echo PERSON SELECT * FROM person; \echo ======= ROLE HIERARCHY ======== SELECT * FROM role; \echo PATIENT SELECT * FROM patient; \echo EMPLOYEE SELECT * FROM employee; \echo ======= ACT HIERARCHY ======== SELECT * FROM act; \echo OBSERVATION SELECT * FROM observation; \echo ======= PARTICIPATION ======= SELECT * FROM participation; /* Community Health and Hospitals has as policy that when information is regarded sensitive, it can be accessed only by the treating physicians. This RLS qual is written in such a way that the subquery is to be pulled up at execution time. */ ALTER TABLE act SET ROW SECURITY FOR ALL TO ( EXISTS ( SELECT 1 FROM employee emp LEFT JOIN participation part ON emp.id = part.role WHERE (part.act = act.id AND emp.pgname = current_user) OR NOT(act.confidentialitycode @> '{s}'))); /* Since observation is a child of act, we need to duplicate the RLS above for observation, otherwise access is open through querying observation directly. */ ALTER TABLE observation SET ROW SECURITY FOR ALL TO ( EXISTS ( SELECT 1 FROM employee emp LEFT JOIN participation part ON emp.id = part.role WHERE (part.act = observation.id AND emp.pgname = current_user) OR NOT(observation.confidentialitycode @> '{s}'))); /* For the sake of testing RLS on tables that are used in other table's RLS: Psychiatrists are not allowed to see any employee records. Other doctors may see all employees. */ /* The following policy causes problems when accessing the employee table ERROR: infinite recursion detected for relation 'employee'. This is good (instead of e.g. infinite regress into a memory error). ALTER TABLE employee SET ROW SECURITY FOR ALL TO ( EXISTS ( SELECT 1 FROM employee emp WHERE (emp.pgname = current_user AND emp.jobcode <> 'Psychiatrist') OR NOT(employee.jobcode = 'Psychiatrist'))); */ /* If we change the restrictions it works */ ALTER TABLE employee SET ROW SECURITY FOR ALL TO ( CASE WHEN current_user = 'sigmund' THEN false ELSE (employee.jobcode != 'Psychiatrist') END); SET SESSION AUTHORIZATION henry; SELECT * FROM act; -- fails, which is good: RLS does not circumvent the usual -- grant privileges. SET SESSION AUTHORIZATION default; GRANT SELECT ON ALL TABLES IN SCHEMA hl7 TO public; GRANT USAGE ON ALL SEQUENCES IN SCHEMA hl7 TO public; GRANT INSERT ON ALL TABLES IN SCHEMA hl7 TO public; GRANT UPDATE ON ALL TABLES IN SCHEMA hl7 TO public; GRANT DELETE ON ALL TABLES IN SCHEMA hl7 TO public; SET SESSION AUTHORIZATION henry; SELECT * FROM act; -- Ok, shows only non-sensitive rows and rows where -- henry is performer. EXPLAIN SELECT * FROM act; -- Good, subquery is pulled up. SELECT * FROM observation; -- Ok, shows only non-sensitive rows and rows where -- henry is performer. SET SESSION AUTHORIZATION sigmund; SELECT * FROM act; -- Ok, shows no information, which is expected since -- to evaluate RLS on act, access to the employee -- table is necessary, which sigmund does not have. SELECT * FROM observation; -- Same here. SET SESSION AUTHORIZATION pete; SELECT * FROM act; -- Ok, shows only non-sensitive rows. SELECT * FROM observation; SELECT * FROM employee; /*** Pete may insert sensitive data.. */ INSERT INTO observation (classcode, moodcode, code, confidentialitycode, value) VALUES ('obs', 'evn', 'bell la padula', '{s}', 130); /* .... even though he cannot read this afterwards */ /* This behaviour is ok for our use cases, but it might be a POLA violation. After all, the ROW SECURITY was set FOR ALL commands, which includes insert. */ SELECT * FROM observation WHERE code = 'bell la padula'; /* ... or update */ UPDATE observation SET code = 'new code' WHERE code = 'bell la padula'; /* ... or delete */ DELETE FROM observation WHERE code = 'bell la padula'; /* Everything is well until now. However, what if we want to restrict access to Patient based on the vipcode? Knowledge about VIP patients can only be seen by doctors having performed a healthcare act for that patient. */ SET SESSION AUTHORIZATION default; ALTER TABLE patient RESET ROW SECURITY FOR ALL; ALTER TABLE patient SET ROW SECURITY FOR ALL TO ( EXISTS ( SELECT 1 FROM employee emp LEFT JOIN participation partemp ON emp.id = partemp.role AND partemp.typecode='prf' LEFT JOIN act ON partemp.act = act.id LEFT JOIN participation partpat ON partpat.role = patient.id AND partpat.typecode = 'rct' WHERE emp.pgname = current_user OR patient.vipcode IS DISTINCT FROM 'vip')); /* The effect of this rule is that only Henry may see the patient row of Isabella. */ SET SESSION AUTHORIZATION henry; SELECT patient.id, vipcode, person.name, birthtime, organization.name FROM patient, person, organization -- ok, shows only non-sensitive rows and WHERE patient.player = person.id -- rows where henry is performer AND patient.scoper = organization.id; EXPLAIN SELECT * FROM patient; SET SESSION AUTHORIZATION sigmund; SELECT * FROM patient; -- ok, shows no information, since sigmund may -- not access the employee table SET SESSION AUTHORIZATION pete; SELECT patient.id, vipcode, person.name, birthtime, organization.name FROM patient, person, organization -- ok, shows only the non-vip rows WHERE patient.player = person.id AND patient.scoper = organization.id; /* There is a problem though that pete can still see that Isabella is a patient through the role table, by accessing the patient info through 'role'. */ SELECT role.id, person.name, birthtime, organization.name FROM role, person, organization WHERE role.player = person.id AND role.scoper = organization.id AND role.classcode = 'pat'; /* This query will show Isabella Jones, but we have no way to specify row security on Role to prevent access to Isabella as vip. */ /* The same holds for policies in the Act hierarchy, if they need to restrict access based on attributes not part of the root table. An example would be to restrict access on e.g. bloodpressures if the values are unusually high. */ /* Summary: the current RLS patch works on inheritance trees for row restrictions that can be specified using only the attributes in the inheritance root relation. Since security labeling in the RIM is done on confidentialitycodes that are present in the inheritance roots Role and Act, the current RLS patch for the RIM. The milage may vary for other non-partitioning use cases of PostgreSQL inheritance. */ /* Views on tables with RLS respect the RLS: good */ SET SESSION AUTHORIZATION default; CREATE VIEW test AS SELECT * FROM act; GRANT ALL ON test TO sigmund; SET SESSION AUTHORIZATION sigmund; SELECT * FROM test; \quit /* Remarks about error messages: ERROR: must be owner of relation employee Is 'relation' the word to use in an error message or 'table'? It would also be more informing in the error log if the user was hinted in some way it was ROW SECURITY that caused the error. ERROR: cannot set row security for table 'employee': not owner or ERROR: must be owner of 'employee' to set row security Dito for this error message ERROR: infinite recursion detected for relation 'employee' what about ERROR: infinite recursion in row security for table 'employee' */