Skip to content

Commit 7e5a1af

Browse files
authored
Merge pull request #2543 from opensafely-core/evansd/table-permissions
Add a permissions system
2 parents d55f1ef + a476462 commit 7e5a1af

34 files changed

+933
-103
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
2+
<h4 class="attr-heading" id="claim_permissions" data-toc-label="claim_permissions" markdown>
3+
<tt><strong>claim_permissions</strong>(<em>*permissions</em>)</tt>
4+
</h4>
5+
<div markdown="block" class="indent">
6+
This function allows you to access any restricted table or feature when working with
7+
dummy data. It will NOT allow you access with real data: for that you will need the
8+
appropriate permissions on the OpenSAFELY platform.
9+
10+
Permission names are strings and should be written with double quotes e.g.
11+
12+
from ehrql import claim_permissions
13+
14+
claim_permissions("some_permission", "another_permission")
15+
16+
This can go anywhere in your dataset or measure definition file.
17+
18+
You can make multiple `claim_permissions()` calls and the permissions will be
19+
combined together.
20+
</div>

docs/includes/generated_docs/schemas/raw.tpp.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,12 @@ Date on which the current dataset was imported.
475475
<p class="dimension-indicator"><code>many rows per patient</code></p>
476476
## isaric
477477

478+
!!! warning "Access to this table requires the `isaric` permission"
479+
480+
Access to ISARIC data is usually agreed at the project application stage. If
481+
you're unsure as to whether you do or should have access please speak to your
482+
co-pilot or to OpenSAFELY support.
483+
478484
ISARIC is a dataset of COVID-19-related hospital admissions,
479485
with coverage across the majority of hospitals across the UK,
480486
including much richer clinical information
@@ -1465,6 +1471,12 @@ Medical condition mentioned on the death certificate.
14651471

14661472
National Waiting List Clock Stops
14671473

1474+
!!! warning "Access to this table requires the `waiting_list` permission"
1475+
1476+
Access to Waiting List data is usually agreed at the project application stage.
1477+
If you're unsure as to whether you do or should have access please speak to your
1478+
co-pilot or to OpenSAFELY support.
1479+
14681480
The columns in this table have the same data types as the columns in [the associated
14691481
database table][wl_clockstops_raw_1]. The three "pseudo" columns are small
14701482
exceptions, as they are converted from binary columns to string columns.
@@ -1614,6 +1626,12 @@ exceptions, as they are converted from binary columns to string columns.
16141626

16151627
National Waiting List Open Pathways
16161628

1629+
!!! warning "Access to this table requires the `waiting_list` permission"
1630+
1631+
Access to Waiting List data is usually agreed at the project application stage.
1632+
If you're unsure as to whether you do or should have access please speak to your
1633+
co-pilot or to OpenSAFELY support.
1634+
16171635
The columns in this table have the same data types as the columns in [the associated
16181636
database table][wl_openpathways_raw_1]. The three "pseudo" columns are small
16191637
exceptions, as they are converted from binary columns to string columns.

docs/includes/generated_docs/schemas/tpp.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,8 @@ The date of discharge from a hospital provider spell.
701701

702702
Appointments in primary care.
703703

704-
!!! warning
704+
!!! warning "Access to this table requires the `appointments` permission"
705+
705706
In TPP this data comes from the "Appointment" table. This table has not yet been
706707
well characterised, so there are some issues around how to interpret findings
707708
from it. The data contains records created when an appointment is made with a GP
@@ -710,8 +711,8 @@ Appointments in primary care.
710711
There are also duplicate events in the table that we need to better understand.
711712

712713
As a consequence, if you try to use the appointment table, you will see warnings
713-
when running your code locally, and failures when the GitHub action tests your
714-
code. If you need access to the appointments data, please speak to your
714+
when running your code locally, and failures if you try to run against real
715+
data. If you need access to the appointments data, please speak to your
715716
OpenSAFELY co-pilot. We will be considering projects on a case by case basis
716717
until it can enter the normal stable pool of data.
717718

@@ -2692,6 +2693,12 @@ The date the referral request was received by the health care provider.
26922693

26932694
This table contains responses to questions from the OpenPROMPT project.
26942695

2696+
!!! warning "Access to this table requires the `open_prompt` permission"
2697+
2698+
Access to OpenPROMPT data is usually agreed at the project application stage. If
2699+
you're unsure as to whether you do or should have access please speak to your
2700+
co-pilot or to OpenSAFELY support.
2701+
26952702
You can find out more about this table in the associated short data report. To view
26962703
it, you will need a login for [Level 4][open_prompt_1]. The
26972704
[workspace][open_prompt_2] shows when the code that comprises the report was run;
@@ -3342,6 +3349,12 @@ used to do so.
33423349
<p class="dimension-indicator"><code>many rows per patient</code></p>
33433350
## ukrr
33443351

3352+
!!! warning "Access to this table requires the `ukrr` permission"
3353+
3354+
Access to UK Renal Registry data is usually agreed at the project application
3355+
stage. If you're unsure as to whether you do or should have access please speak
3356+
to your co-pilot or to OpenSAFELY support.
3357+
33453358
The UK Renal Registry (UKRR) contains data on patients under secondary renal care
33463359
(advanced chronic kidney disease stages 4 and 5, dialysis, and kidney transplantation)
33473360
<div markdown="block" class="definition-list-wrapper">
@@ -3527,6 +3540,12 @@ Vaccine's product name.
35273540

35283541
Waiting List Minimum Data Set Clock Stops
35293542

3543+
!!! warning "Access to this table requires the `waiting_list` permission"
3544+
3545+
Access to Waiting List data is usually agreed at the project application stage.
3546+
If you're unsure as to whether you do or should have access please speak to your
3547+
co-pilot or to OpenSAFELY support.
3548+
35303549
These data are from the patient-level [Waiting List Minimum Data Set (WLMDS)](https://www.england.nhs.uk/statistics/statistical-work-areas/rtt-waiting-times/wlmds/),
35313550
which are reported separately from the aggregate [Referral to Treatment (RTT) data](https://www.england.nhs.uk/statistics/statistical-work-areas/rtt-waiting-times/).
35323551

@@ -3712,6 +3731,12 @@ The Sunday of the week that the pathway relates to
37123731

37133732
Waiting List Minimum Data Set Open Pathways
37143733

3734+
!!! warning "Access to this table requires the `waiting_list` permission"
3735+
3736+
Access to Waiting List data is usually agreed at the project application stage.
3737+
If you're unsure as to whether you do or should have access please speak to your
3738+
co-pilot or to OpenSAFELY support.
3739+
37153740
These data are from the patient-level [Waiting List Minimum Data Set (WLMDS)](https://www.england.nhs.uk/statistics/statistical-work-areas/rtt-waiting-times/wlmds/),
37163741
which are reported separately from the aggregate [Referral to Treatment (RTT) data](https://www.england.nhs.uk/statistics/statistical-work-areas/rtt-waiting-times/).
37173742

docs/reference/language.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ other) groupings.
130130

131131
---8<-- 'includes/generated_docs/language__measures.md'
132132

133+
---
134+
133135

134136
## Parameters
135137

@@ -139,3 +141,42 @@ from the [command line](cli.md#generate-dataset.user_args).
139141
---8<-- 'includes/generated_docs/language__parameters.md'
140142

141143
---
144+
145+
146+
## Permissions
147+
148+
Some tables and features in ehrQL can only be accessed with specific
149+
permission, which must be enabled by the OpenSAFELY team. The exact
150+
process and criteria for granting these permissions depends on the
151+
details of the specific permission in question. But generally speaking
152+
this would something discussed at the application stage of a project.
153+
154+
If there is a permission you think you should have but don't, you should
155+
raise this with your co-pilot or with OpenSAFELY support.
156+
157+
158+
### “Claiming” permissions
159+
160+
When working with dummy data locally or inside Codespaces ehrQL does not
161+
know which permissions your project has and so it will warn if you
162+
attempt to use _any_ restricted table or feature. You can silence these
163+
warnings by using the `claim_permission()` function below to say which
164+
permissions your project has.
165+
166+
The warning message generated by ehrQL will tell you exactly what
167+
permissions you need to claim and will generate the code you need to
168+
copy and paste into your dataset definition.
169+
170+
You can claim any permission you need to allow you to continue your
171+
dummy data work, but you **won't be able to run your project against
172+
real data** unless you actually have those permissions enabled in the
173+
OpenSAFELY platform.
174+
175+
The `claim_permissions()` function can be placed anywhere in your
176+
dataset or measure definition file, or in any of the Python files your
177+
definition imports.
178+
179+
180+
---8<-- 'includes/generated_docs/language__permissions.md'
181+
182+
---

ehrql/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from ehrql.codes import codelist_from_csv
44
from ehrql.debugger import show
55
from ehrql.measures import INTERVAL, Measures, create_measures
6+
from ehrql.permissions import claim_permissions
67
from ehrql.query_language import (
78
Dataset,
89
Error,
@@ -24,6 +25,7 @@
2425

2526

2627
__all__ = [
28+
"claim_permissions",
2729
"codelist_from_csv",
2830
"INTERVAL",
2931
"Measures",

ehrql/__main__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
split_directory_and_extension,
2020
)
2121
from ehrql.loaders import DEFINITION_LOADERS, DefinitionError
22+
from ehrql.permissions import EHRQLPermissionError
2223
from ehrql.renderers import DISPLAY_RENDERERS
2324
from ehrql.utils.string_utils import strip_indent
2425

@@ -123,11 +124,14 @@ def main(args, environ=None):
123124
# Errors from definition files are already pre-formatted so we just write them
124125
# directly to stderr and exit
125126
print(str(exc), file=sys.stderr)
126-
sys.exit(1)
127+
sys.exit(10)
127128
except FileValidationError as exc:
128129
# Handle errors encountered while reading user-supplied data
129130
print(f"{exc.__class__.__name__}: {exc}", file=sys.stderr)
130-
sys.exit(1)
131+
sys.exit(11)
132+
except EHRQLPermissionError as exc:
133+
print(f"{exc.__class__.__name__}: {exc}", file=sys.stderr)
134+
sys.exit(12)
131135
except Exception as exc:
132136
# For functions which take a `backend_class` give that class the chance to set
133137
# the appropriate exit status for any errors

ehrql/docs/language.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ def build_language():
110110
"parameters": {
111111
"get_parameter": namespace["get_parameter"],
112112
},
113+
"permissions": {
114+
"claim_permissions": namespace["claim_permissions"],
115+
},
113116
}
114117

115118
# Check that the documentation is complete

ehrql/docs/schemas.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,22 +66,36 @@ def build_module_name_to_backend_map(backends):
6666

6767
def build_tables(module):
6868
for table_name, table in get_tables_from_namespace(module):
69-
cls = table.__class__
70-
docstring = get_table_docstring(cls)
71-
columns = [
72-
build_column(table_name, column_name, series_or_property)
73-
for column_name, series_or_property in get_all_series_and_properties_from_class(
74-
cls
75-
).items()
76-
]
69+
yield build_table(table_name, table)
70+
71+
72+
def build_table(table_name, table):
73+
cls = table.__class__
74+
docstring = get_table_docstring(cls)
75+
required_permission = table._qm_node.required_permission
76+
columns = [
77+
build_column(table_name, column_name, series_or_property)
78+
for column_name, series_or_property in get_all_series_and_properties_from_class(
79+
cls
80+
).items()
81+
]
7782

78-
yield {
79-
"name": table_name,
80-
"docstring": docstring,
81-
"columns": columns,
82-
"has_one_row_per_patient": issubclass(cls, PatientFrame),
83-
"methods": build_table_methods(table_name, cls),
84-
}
83+
if required_permission:
84+
expected_string = f"`{required_permission}` permission"
85+
if expected_string not in docstring:
86+
raise ValueError(
87+
f"Table {cls!r} requires the {required_permission!r} permission "
88+
f"but doesn't include {expected_string!r} in its docstring"
89+
)
90+
91+
return {
92+
"name": table_name,
93+
"docstring": docstring,
94+
"columns": columns,
95+
"has_one_row_per_patient": issubclass(cls, PatientFrame),
96+
"methods": build_table_methods(table_name, cls),
97+
"required_permission": required_permission,
98+
}
8599

86100

87101
def build_column(table_name, column_name, series_or_property):

ehrql/loaders.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import ehrql
1010
from ehrql.debugger import activate_debug_context
1111
from ehrql.measures import Measures
12+
from ehrql.permissions import clear_claimed_permissions, get_claimed_permissions
1213
from ehrql.query_language import Dataset, modify_exception
1314
from ehrql.renderers import DISPLAY_RENDERERS
1415
from ehrql.serializer import deserialize
@@ -250,7 +251,7 @@ def isolation_report_for_function(run_function, cwd):
250251
def load_dataset_definition_unsafe(definition_file, user_args, **kwargs):
251252
module = load_module(definition_file, user_args)
252253
dataset = get_dataset_from_module(module)
253-
return dataset, module.dataset.dummy_data_config
254+
return dataset, module.dataset.dummy_data_config, module._claimed_permissions
254255

255256

256257
def load_test_definition_unsafe(definition_file, user_args, **kwargs):
@@ -298,9 +299,10 @@ def load_measure_definitions_unsafe(definition_file, user_args, **kwargs):
298299
if len(measures) == 0:
299300
raise DefinitionError("No measures defined")
300301
return (
301-
list(measures),
302+
measures._compile(),
302303
measures.dummy_data_config,
303304
measures.disclosure_control_config,
305+
module._claimed_permissions,
304306
)
305307

306308

@@ -324,10 +326,9 @@ def load_definition_unsafe(
324326

325327

326328
def load_module(module_path, user_args=()):
327-
"""Load a module containing an ehrql dataset definition.
328-
329-
Renders the serialized query model as json to the callers stdout, and any
330-
other output to stderr.
329+
"""
330+
Load a Python module by its filesystem path and return it, with some custom
331+
ehrQL-specific behaviour
331332
"""
332333

333334
# Taken from the official recipe for importing a module from a file path:
@@ -343,13 +344,17 @@ def load_module(module_path, user_args=()):
343344
# generally looks as it would had you run: `python script.py some args --here`
344345
original_sys_argv = sys.argv.copy()
345346
sys.argv = [str(module_path), *user_args]
346-
# Force any user generated output (prints etc) to stderr so it doe not get
347+
# Force any user generated output (prints etc) to stderr so it does not get
347348
# mixed up with anything we might want to output ourselves
348349
original_sys_stdout = sys.stdout
349350
sys.stdout = sys.stderr
351+
# Reset any previously claimed permissions
352+
clear_claimed_permissions()
350353

351354
try:
352355
spec.loader.exec_module(module)
356+
# Attach a tuple of any claimed permissions to the module namespace
357+
module._claimed_permissions = get_claimed_permissions()
353358
return module
354359
except Exception as exc:
355360
# Give the query langauge the chance to modify or replace the exception

0 commit comments

Comments
 (0)