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