From ea00c5afad0daf2a66cdb206f2a7481b992192bf Mon Sep 17 00:00:00 2001 From: 4gl <@> Date: Wed, 22 Jul 2026 13:44:19 +0100 Subject: [PATCH] feat: validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes #211 --- .agent/docs/row-level-security.md | 9 + .../services/editors/stagedata.test.3.sas | 196 ++++++++++++++++++ .../hooks/mpe_row_level_security_postedit.sas | 31 +++ .../services/hooks/mpe_tables_postedit.sas | 27 +++ 4 files changed, 263 insertions(+) create mode 100644 sas/sasjs/services/editors/stagedata.test.3.sas diff --git a/.agent/docs/row-level-security.md b/.agent/docs/row-level-security.md index efa0ad1..abd4098 100644 --- a/.agent/docs/row-level-security.md +++ b/.agent/docs/row-level-security.md @@ -171,6 +171,15 @@ run; If even one submitted row falls outside the user's permitted row set, the entire staging request is aborted before any approval/apply step. +## Incompatibility with REPLACE Load Type + +RLS with `EDIT` scope is **incompatible** with tables configured with `LOAD_TYPE=REPLACE` in `MPE_TABLES`. A REPLACE load wipes and reloads the entire target table, so row-level filtering of submitted records cannot be enforced meaningfully (the rows a user is *not* allowed to see would also be deleted). Backend validations therefore abort in both directions (see [issue #211](https://git.datacontroller.io/dc/dc/issues/211)): + +1. **`mpe_row_level_security_postedit.sas`** — aborts when activating a rule with `RLS_SCOPE in ('EDIT','ALL')` against a table whose current `MPE_TABLES` record has `LOADTYPE='REPLACE'`. +2. **`mpe_tables_postedit.sas`** — aborts when setting `LOADTYPE='REPLACE'` on a table that already has active, current `RLS_SCOPE in ('EDIT','ALL')` rules in `MPE_ROW_LEVEL_SECURITY`. + +`VIEW`-scope rules remain compatible with REPLACE loads, since they only affect read paths. + ## Edit-Time Validation (Injection Defence) Because `RLS_RAW_VALUE` is free text that ends up inside a generated WHERE clause, it is a potential SAS code-injection vector. Mitigations: diff --git a/sas/sasjs/services/editors/stagedata.test.3.sas b/sas/sasjs/services/editors/stagedata.test.3.sas new file mode 100644 index 0000000..ad4dad5 --- /dev/null +++ b/sas/sasjs/services/editors/stagedata.test.3.sas @@ -0,0 +1,196 @@ +/** + @file + @brief testing stagedata RLS / REPLACE loadtype incompatibility (#211) + @details Backend validations should abort when: + 1. loading row level EDIT security against a table with LOADTYPE=REPLACE + 2. applying LOADTYPE=REPLACE to a table with row level EDIT security + +

SAS Macros

+ @li mx_testservice.sas + @li mp_assert.sas + + +**/ + +%let _program=&appLoc/services/editors/stagedata; + +/** + * Prep - register a DCTEST table with REPLACE loadtype (direct insert, + * bypassing stagedata) and create the physical table + */ +data dctest.dc_replace; + x=1; +run; +proc sql noprint; +delete from &dc_libref..mpe_tables where libref="DCTEST" and dsn='DC_REPLACE'; +insert into &dc_libref..mpe_tables + set tx_from=0 + ,tx_to='31DEC5999:23:59:59'dt + ,libref="DCTEST" + ,dsn='DC_REPLACE' + ,buskey='X' + ,loadtype='REPLACE' + ,num_of_approvals_required=1; +quit; + +/** + * Test 1 - submitting an RLS EDIT rule against a REPLACE table should abort + */ +data work.sascontroltable; + action='LOAD'; + message='RLS EDIT rule on REPLACE table should abort'; + libds="&dc_libref..MPE_ROW_LEVEL_SECURITY"; + output; + stop; +run; + +data work.jsdata; + length RLS_SCOPE $8 RLS_GROUP $128 RLS_LIBREF $8 RLS_TABLE $32 + RLS_GROUP_LOGIC $3 RLS_SUBGROUP_LOGIC $3 RLS_VARIABLE_NM $32 + RLS_OPERATOR_NM $12 RLS_RAW_VALUE $4000 + _____DELETE__THIS__RECORD_____ $3; + RLS_SCOPE='EDIT'; + RLS_GROUP='SASAdministrators'; + RLS_LIBREF='DCTEST'; + RLS_TABLE='DC_REPLACE'; + RLS_GROUP_LOGIC='AND'; + RLS_SUBGROUP_LOGIC='OR'; + RLS_SUBGROUP_ID=0; + RLS_VARIABLE_NM='X'; + RLS_OPERATOR_NM='NE'; + RLS_RAW_VALUE='1'; + RLS_ACTIVE=1; + _____DELETE__THIS__RECORD_____='No'; + output; +run; + +%mx_testservice(&_program, + viyacontext=&defaultcontext, + inputdatasets=work.sascontroltable work.jsdata, + outlib=web1, + outref=wb1, + mdebug=&sasjs_mdebug +) + +%let abort1=0; +data _null_; + infile wb1; + input; + putlog _infile_; + if index(_infile_,'sasjsAbort') + and index(_infile_,'REPLACE loadtype') + then call symputx('abort1',1); +run; + +%mp_assert( + iftrue=(&abort1=1), + desc=Checking RLS EDIT rule is rejected for REPLACE loadtype table (#211), + outds=work.test_results +) + +/** + * Test 2 - applying REPLACE loadtype to a table with RLS EDIT security + * should abort. First insert an active EDIT rule directly. + */ +proc sql noprint; +delete from &dc_libref..mpe_row_level_security + where rls_libref="DCTEST" and rls_table='WIDEBOY'; +select coalesce(max(rls_rk),0)+1 into: rlsrk + from &dc_libref..mpe_row_level_security; +insert into &dc_libref..mpe_row_level_security + set tx_from=0 + ,tx_to='31DEC5999:23:59:59'dt + ,rls_rk=&rlsrk + ,rls_scope='EDIT' + ,rls_group='SASAdministrators' + ,rls_libref='DCTEST' + ,rls_table='WIDEBOY' + ,rls_group_logic='AND' + ,rls_subgroup_logic='OR' + ,rls_subgroup_id=0 + ,rls_variable_nm='ROW_ID' + ,rls_operator_nm='NE' + ,rls_raw_value='1' + ,rls_active=1; +quit; + +/* now stage a REPLACE loadtype for DCTEST.WIDEBOY */ +data work.sascontroltable; + action='LOAD'; + message='REPLACE loadtype on RLS-secured table should abort'; + libds="&dc_libref..MPE_TABLES"; + output; + stop; +run; + +data work.jsdata; + set &dc_libref..mpe_tables(where=(libref='DCTEST' and dsn='WIDEBOY')); + loadtype='REPLACE'; + length _____DELETE__THIS__RECORD_____ $3; + _____DELETE__THIS__RECORD_____='No'; +run; + +%mx_testservice(&_program, + viyacontext=&defaultcontext, + inputdatasets=work.sascontroltable work.jsdata, + outlib=web2, + outref=wb2, + mdebug=&sasjs_mdebug +) + +%let abort2=0; +data _null_; + infile wb2; + input; + putlog _infile_; + if index(_infile_,'sasjsAbort') + and index(_infile_,'row level EDIT security') + then call symputx('abort2',1); +run; + +%mp_assert( + iftrue=(&abort2=1), + desc=Checking REPLACE loadtype is rejected for RLS EDIT-secured table (#211), + outds=work.test_results +) + +/** + * Test 3 - sanity check: REPLACE loadtype on an unsecured table is fine + */ +data work.jsdata; + set &dc_libref..mpe_tables(where=(libref='DCTEST' and dsn='WIDEBOY')); + loadtype='UPDATE'; + length _____DELETE__THIS__RECORD_____ $3; + _____DELETE__THIS__RECORD_____='No'; +run; + +/* remove the RLS rule first (cleanup of test 2) */ +proc sql noprint; +delete from &dc_libref..mpe_row_level_security + where rls_libref="DCTEST" and rls_table='WIDEBOY'; +quit; + +%mx_testservice(&_program, + viyacontext=&defaultcontext, + inputdatasets=work.sascontroltable work.jsdata, + outlib=web3, + mdebug=&sasjs_mdebug +) + +%let status3=0; +data _null_; + set web3.sasparams; + putlog (_all_)(=); + if status='SUCCESS' then call symputx('status3',1); +run; + +%mp_assert( + iftrue=(&status3=1), + desc=Checking non-REPLACE loadtype still stages successfully after cleanup, + outds=work.test_results +) + +/* final cleanup - remove the REPLACE table registration */ +proc sql noprint; +delete from &dc_libref..mpe_tables where libref="DCTEST" and dsn='DC_REPLACE'; +quit; diff --git a/sas/sasjs/services/hooks/mpe_row_level_security_postedit.sas b/sas/sasjs/services/hooks/mpe_row_level_security_postedit.sas index 8d10d0e..728387e 100644 --- a/sas/sasjs/services/hooks/mpe_row_level_security_postedit.sas +++ b/sas/sasjs/services/hooks/mpe_row_level_security_postedit.sas @@ -13,8 +13,12 @@ This validation checks the incoming row_level_security settings to ensure each individual filter is + It also checks that EDIT scope security is not being applied to tables + with a REPLACE loadtype (these are incompatible - see issue #211). +

SAS Macros

@li dc_assignlib.sas + @li mp_abort.sas @li mp_filtercheck.sas

Related Macros

@@ -23,6 +27,33 @@ **/ +/* REPLACE loadtype is incompatible with row level EDIT security (#211) */ +proc sql noprint; +create table work.badloadtypes as + select distinct cats(upcase(a.rls_libref),'.',upcase(a.rls_table)) as libds + from work.staging_ds(where=(rls_active=1 + and upcase(rls_scope) in ('EDIT','ALL') + and upcase(_____DELETE__THIS__RECORD_____) ne 'YES')) a + inner join &dc_libref..mpe_tables(where=(&dc_dttmtfmt. lt tx_to)) b + on upcase(a.rls_libref)=upcase(b.libref) + and upcase(a.rls_table)=upcase(b.dsn) + where upcase(b.loadtype)='REPLACE'; + +%let badloadtypes=0; +%let badloadtype=; +data _null_; + set work.badloadtypes; + call symputx('badloadtypes',_n_); + call symputx('badloadtype',libds); +run; + +%mp_abort(iftrue=(&badloadtypes ne 0) + ,mac=mpe_row_level_security_postedit + ,msg=%str(Row level EDIT security cannot be applied to table(s) with a%trim( + ) REPLACE loadtype, eg: &badloadtype. Remove the security rule(s) or %trim( + )change the loadtype.) +) + /* ignore scope and group for validation */ proc sql; create table work.batches as diff --git a/sas/sasjs/services/hooks/mpe_tables_postedit.sas b/sas/sasjs/services/hooks/mpe_tables_postedit.sas index 974c0bd..395a4af 100644 --- a/sas/sasjs/services/hooks/mpe_tables_postedit.sas +++ b/sas/sasjs/services/hooks/mpe_tables_postedit.sas @@ -69,6 +69,33 @@ run; ,msg=%superq(errmsg) ) +/* REPLACE loadtype is incompatible with row level EDIT security (#211) */ +proc sql noprint; +create table work.badloadtypes as + select distinct cats(upcase(a.libref),'.',upcase(a.dsn)) as libds + from work.staging_ds(where=(loadtype='REPLACE' + and upcase(_____DELETE__THIS__RECORD_____) ne 'YES')) a + inner join &dc_libref..mpe_row_level_security(where=( + rls_active=1 and &dc_dttmtfmt. lt tx_to + and upcase(rls_scope) in ('EDIT','ALL'))) b + on upcase(a.libref)=upcase(b.rls_libref) + and upcase(a.dsn)=upcase(b.rls_table); + +%let badloadtypes=0; +%let badloadtype=; +data _null_; + set work.badloadtypes; + call symputx('badloadtypes',_n_); + call symputx('badloadtype',libds); +run; + +%mp_abort(iftrue=(&badloadtypes ne 0) + ,mac=mpe_tables_postedit + ,msg=%str(REPLACE loadtype cannot be applied to table(s) with row level %trim( + )EDIT security, eg: &badloadtype. Remove the security rule(s) or change%trim( + ) the loadtype.) +) + /* get distinct list of audit libs */ proc sql; create table work.liblist as