ServiceNow Before Query Business Rule — 행 단위 보안(Row-Level Security)과 OR 그룹핑 누수 함정
ACL이 접근 권한을 결정한다면 before query business rule은 쿼리 단계에서 행을 조용히 제거한다. current.addQuery 패턴, addOrCondition의 master-OR 부재로 인한 권한 누수, admin·isInteractive 우회, 모든 쿼리에 적용되는 성능 비용까지 실무 관점으로 정리.
개요
ServiceNow 데이터 보호는 두 축이 있다. (1) ACL(Access Control List) — “이 레코드/필드에 접근 가능한가”를 결정한다. Phase 3단 평가와 OR vs AND 결합 세부 동작은 ACL 평가 순서와 디버깅 함정 글에서 다뤘다. (2) before query business rule — “쿼리 결과 집합에 이 행이 포함되는가”를 쿼리 실행 직전에 결정한다.
ACL이 레코드를 거부하면 리스트 하단에 “N rows removed by Security constraints” 류 메시지가 표시되어 숨겨진 행의 존재와 개수가 노출된다(OOTB 기준, 버전에 따라 문구 다름). before query BR은 행 자체를 쿼리 단계에서 제거하므로 메시지도 개수도 남지 않는다 — 사용자는 숨겨진 행의 존재 자체를 모른다.
이 글은 메커니즘, ACL과의 차이, OR 결합 함정과 안전 패턴을 다룬다.
OOTB(Out-of-the-Box, 기본 제공) 기준입니다. 인스턴스 버전·플러그인 구성에 따라 동작이 달라질 수 있습니다.
§1 — Before Query Business Rule 메커니즘
Business Rule 설정 폼에서 When = before + Query 체크박스 활성화가 핵심이다. Insert/Update/Delete 시점 일반 before BR과 달리, before query BR은 GlideRecord 쿼리가 데이터베이스에 전달되기 직전에 실행된다.
이 시점에서 current는 저장 중인 레코드가 아니라 실행 직전의 쿼리 GlideRecord 오브젝트다. current.addQuery(...) 를 호출하면 그 조건이 기존 쿼리에 AND로 결합되어 전달된다. 조건을 충족하지 못하는 행은 쿼리 결과에서 처음부터 제외된다.
적용 범위가 매우 넓다는 점을 특히 주의해야 한다. 해당 테이블의 거의 모든 조회에 적용된다: 리스트, 관련 리스트(related list), 리포트, 그리고 다른 서버 스크립트의 GlideRecord 쿼리·REST Table API·통합(integration) 경로까지.
즉 일반 before BR의 current는 저장될 레코드(setValue()로 필드 조작)지만, before query BR의 current는 쿼리 오브젝트(addQuery()로 필터 주입)다 — 레코드 저장과 무관하다.
§2 — ACL vs Before Query BR: 거부 메시지 vs 조용한 제거
| 항목 | ACL read 거부 | Before Query BR |
|---|---|---|
| 적용 시점 | 레코드를 꺼낸 뒤 접근 권한 평가 | 쿼리가 DB에 전달되기 전 |
| 사용자에게 표시되는 것 | 리스트 하단에 “N rows removed by Security constraints” 류 메시지 노출 (OOTB 기준, 버전에 따라 문구 다름) | 메시지 없음 — 행 자체가 결과에 없는 것처럼 보임 |
| 숨긴 행 개수 노출 여부 | 노출됨 (숨겨진 행 존재 + 개수) | 노출 없음 |
| 디버깅 용이성 | Security Debug 모드로 추적 가능 | 조용히 동작 — admin이나 background script로 비교 쿼리 필요 |
| 성능 비용 | 조회된 행에 대한 ACL 평가 비용 | 모든 쿼리에 조건 추가 비용 — 인덱스 없는 필드 기준이면 풀스캔 위험 |
| 주요 용도 | 필드·레코드 단위 접근 권한 제어 | 대량 행 가시성 분할 (부서별·담당자별 파티셔닝) |
ACL과 before query BR은 대체 관계가 아니라 보완 관계다. 필드·레코드 접근 권한에는 ACL을, 존재 자체를 감춰야 하는 민감 데이터(HR·급여 등) 행 분할에는 before query BR을 쓴다.
§3 — 기본 패턴과 가드(guard)
가장 흔히 쓰이는 before query BR 스니펫이다.
(function executeRule(current, previous /*null when async*/) {
if (!gs.hasRole('admin') && gs.getSession().isInteractive()) {
current.addQuery('assigned_to', gs.getUserID());
}
})(current, previous);
단순해 보이지만 이 가드 구조에는 실무에서 놓치기 쉬운 함정이 세 가지 있다.
함정 (a) — gs.hasRole('admin') 동작
gs.hasRole('admin')은 admin 사용자에 대해 어떤 역할명을 묻든 항상 true를 반환한다. !gs.hasRole('admin') 가드가 admin을 필터에서 제외하는 이유다. 세분화된 역할로 체크를 짜도 admin은 그 역할이 없어도 항상 제외됨을 기억해야 한다.
함정 (b) — isInteractive() 와 impersonation
gs.getSession().isInteractive()는 UI 직접 상호작용 세션인지 판단한다 — 기본적으로 스케줄·백그라운드 컨텍스트에서는 false라 이들을 필터에서 제외한다. 단, impersonation이 얽힌 일부 시나리오에서는 세션 타입 판정이 직관과 달라질 수 있다. isInteractive() 단독 가드에 의존하기보다, 영향받는 스케줄·통합 경로에서 before query BR 필터가 의도치 않게 적용되지 않는지 배포 전 실제로 검증해야 한다.
함정 (c) — 가드 없이 두면 모든 조회에 적용
가드 없이 current.addQuery(...) 만 작성하면 해당 테이블의 백그라운드 스크립트·통합 쿼리·REST API 호출 전체에 예외 없이 적용된다. 오류 로그 없이 레코드만 안 잡히므로, 통합이 레코드를 “못 찾는” 장애의 흔한 숨은 원인이 된다.
§4 — OR 조건 결합의 함정과 안전 패턴
이 섹션이 before query BR에서 가장 위험한 부분이다. 잘못된 OR 결합은 보안 누수로 이어질 수 있다.
ServiceNow 인코디드 쿼리의 OR 동작
먼저 확인할 사실 세 가지다.
addOrCondition()은 인코디드 쿼리의^OR연산자를 만든다.^OR은 인접한 조건끼리 묶는 지역(local) OR이다. 예:priority=1^ORpriority=2→ “priority가 1 또는 2”.- ServiceNow 인코디드 쿼리는 괄호로 그룹을 강제할 수 없다.
(A OR B) AND C를 괄호로 명시하는 문법 자체가 없다. - 쿼리 전체를 두 블록으로 OR 하려면(= “master OR”)
^OR이 아니라^NQ(New Query) 연산자가 필요하다:A^B^NQC^D→(A AND B) OR (C AND D). 그런데addOrCondition()은^OR만 만들고^NQ는 만들지 않는다 — GlideRecord에는 master OR를 직접 만드는 간단한 메서드가 없다.
그렇다고 인코디드 쿼리를 손으로 짜서 ^NQ를 끼워넣는 우회도 before query BR 안에서는 위험하다. ^NQ 뒤 블록이 사용자가 리스트에 적용한 필터를 무시하고 강제로 매칭되는 동작이 보고돼 있다. 증상은 교묘하다 — 리스트 자체는 정상으로 보이는데 행을 클릭하면 엉뚱한 레코드가 열린다(플랫폼이 클릭한 행이 아닌 다른 행을 선택). before query BR 맥락에서 ^NQ를 신뢰하지 말아야 하는 이유이며, 아래 안전 패턴이 OR 자체를 메인 쿼리에서 분리하는 근거이기도 하다.
왜 before query BR에서 위험한가
before query BR은 사용자가 리스트에서 이미 입력한 필터 위에 조건을 덧붙인다. 보안 조건을 OR로 완화하거나 사용자 필터에 OR이 섞이면, AND/OR 결합이 괄호 없이 만들어진다. 그 결과 보안 조건이 모든 분기에 적용되지 않고 일부 분기만 제약해 숨겨야 할 행이 샐 수 있다.
전형적 실수는 보안 경계를 OR로 완화하는 것이다.
// ⚠ 위험 — 부서를 보안 경계로 삼으면서 OR로 완화
current.addQuery('department', gs.getUser().getDepartmentID())
.addOrCondition('assigned_to', gs.getUserID());
부서가 반드시 지켜져야 하는 보안 경계라면 이 코드는 경계를 깬다 — 다른 부서 레코드라도 나에게 할당돼 있으면 보이기 때문이다. 보안 경계는 OR로 완화하는 순간 더 이상 경계가 아니다. 사용자가 리스트 필터에 이미 OR을 넣은 경우는 더 까다롭다. BR이 더한 AND 보안 조건이 사용자 OR의 일부 분기에만 결합될 수 있고, 괄호로 교정할 수 없으므로 결합 결과는 인스턴스에서 직접 확인하지 않으면 예측이 어렵다.
안전 패턴
- 보안 경계는 평범한 AND
addQuery()로만 표현한다. 보안 조건에는addOrCondition()을 쓰지 않는다. - OR 논리(여러 허용 그룹)가 꼭 필요하면, 별도 GlideRecord로 허용된
sys_id목록을 먼저 수집해 단일 조건으로 제한한다. OR은 사용자 필터와 섞이지 않는 독립 쿼리 안에 가둔다.
(function executeRule(current, previous) {
if (!gs.hasRole('admin') && gs.getSession().isInteractive()) {
var allowedIds = [];
var gr = new GlideRecord('incident');
gr.addQuery('department', gs.getUser().getDepartmentID());
gr.addOrCondition('assigned_to', gs.getUserID()); // 독립 쿼리 안의 OR — 안전
gr.query();
while (gr.next()) allowedIds.push(gr.getUniqueValue());
if (allowedIds.length) {
current.addQuery('sys_id', 'IN', allowedIds.join(','));
} else {
current.addNullQuery('sys_id'); // 허용 레코드 없음 → 빈 결과
}
}
})(current, previous);
OR 논리를 전처리 쿼리에 가두고 메인 쿼리에는 단일 조건만 더한다. 단 추가 쿼리가 한 번 더 발생하고, 허용 집합이 매우 크면 sys_id IN 목록 길이가 성능에 부담이 되므로, 가능하면 인덱스 있는 단일 컬럼(AND) 조건으로 경계를 표현하는 편이 낫다.
- 배포 전 결합 결과 검증: 일반 사용자로 impersonate해 OR이 포함된 리스트 필터를 적용하고, 숨겨야 할 행이 새지 않는지 확인한다.
추가 SQL 함정 — != 조건과 blank/NULL 행 제거
문자열 필드에 !=(NOT EQUAL) 조건을 사용하면 SQL 3-값 논리(TRUE / FALSE / NULL)에 의해 field가 NULL(비어있는) 행도 결과에서 제외된다.
// ⚠ blank 값 레코드도 함께 제거됨
current.addQuery('u_security_level', '!=', 'restricted');
// ✅ blank 포함하는 안전한 패턴
var qc = current.addQuery('u_security_level', '!=', 'restricted');
qc.addOrCondition('u_security_level', '');
“비어있는 레코드가 갑자기 사라지는” 현상의 흔한 원인이다.
§5 — OOTB 예시·디버깅·성능 체크리스트
OOTB 예시 — 비활성 사용자가 참조 필드에서 “사라지는” 이유
sys_user 참조 필드 팝업이나 자동완성에서 비활성 사용자가 보이지 않는다면, 원인은 OOTB before query BR이다. OOTB 기준으로 sys_user 테이블에 active = true 조건을 주입하는 before query BR이 존재해, 일반 조회 맥락에서 비활성 사용자를 제외한다.
더 골치 아픈 부수효과가 있다. 이 BR은 리스트뿐 아니라 이미 저장된 참조 필드 표시에도 적용된다. 비활성 사용자가 Caller로 지정된 기존 incident를 비-admin이 열면, sys_id 데이터는 그대로인데 표시값을 가져오는 쿼리가 BR에 막혀 참조 필드가 빈 칸으로 보인다. “데이터는 있는데 화면엔 비어 보이는” 현상의 전형적 원인이다.
널리 쓰이는 보정은 특정 레코드를 sys_id로 직접 조회하는 경우엔 필터를 건너뛰는 것이다. BR 조건에서 인코디드 쿼리가 sys_id= 로 시작하는지 검사해(current.getEncodedQuery().indexOf('sys_id=') != 0 일 때만 active 필터 적용) 단건 조회를 우회시키면, 리스트에서는 비활성 사용자를 숨기되 참조 필드 표시는 정상화된다 (정확한 OOTB BR 명칭·조건은 인스턴스에서 직접 확인 권장).
디버깅 팁
Before query BR은 조용히 동작해 인지가 어렵다. 가장 효과적인 진단법은 admin 또는 background script로 같은 쿼리를 실행해 결과 차이를 비교하는 것이다. admin은 !gs.hasRole('admin') 가드로 BR 조건에서 제외되므로, 일반 사용자에게 안 보이는 레코드가 admin에게 보인다면 before query BR을 의심할 수 있다.
setWorkflow(false)로 BR 실행을 우회할 수도 있지만, 이는 before query BR이 구현한 보안 제어를 완전히 무력화한다. 진단 목적으로만 사용해야 한다. 차단 범위와 주의사항은 Business Rule 무한 루프와 setWorkflow(false) 우회 글을 참고.
운영 체크리스트
- 가드 적용:
!gs.hasRole('admin')+gs.getSession().isInteractive()— 통합·백그라운드 잡 영향 차단 - OR 대신 sys_id IN:
addOrCondition()직접 사용 대신 허용 집합을 미리 수집해sys_id IN단일 조건으로 제한 !=조건 blank 부수효과 점검: 문자열 필드 NOT EQUAL 조건은 빈 값 행도 제외 — 명시적 OR 처리 추가- 성능 — 인덱스 있는 필드:
addQuery()주입 조건 필드에 인덱스 없으면 대량 테이블 풀스캔 위험. Dictionary에서 인덱스 확인 - 통합 영향 검토: 새 before query BR 배포 전 REST API/통합 경로 쿼리에 의도치 않은 필터링 발생하는지 확인
참조
- ACL 평가 순서와 디버깅 함정 — 접근 결정 레이어(phase 3단, OR vs AND 결합) 상세 정리
- Business Rule 무한 루프와 setWorkflow(false) 우회 — setWorkflow 차단 범위와 조용한 실패 패턴
- ServiceNow Docs — Before Query business rules — 공식 문서