본문으로 건너뛰기
Paul's Dev Notes

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/통합 경로 쿼리에 의도치 않은 필터링 발생하는지 확인

참조