feat: validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes #211

This commit is contained in:
4gl
2026-07-22 13:44:19 +01:00
parent 359d833406
commit ea00c5afad
4 changed files with 263 additions and 0 deletions
+9
View File
@@ -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:
@@ -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
<h4> SAS Macros </h4>
@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;
@@ -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).
<h4> SAS Macros </h4>
@li dc_assignlib.sas
@li mp_abort.sas
@li mp_filtercheck.sas
<h4> Related Macros </h4>
@@ -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
@@ -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