---
name: Fence Sensitive ServiceNow Tables
tags:
  - servicenow
  - fence-sensitive-tables
  - pii
  - ingress
  - soc2
  - hipaa
  - pci-dss
  - gdpr-ccpa
publishedAt: 2026-07-12
description: |
  # servicenow / fence-sensitive-tables

  **Direction:** ingress (`tool_pre_invoke`)
  **Default:** deny on a sensitive, ungrouped table; allow otherwise
  **Package:** `servicenow.ingress.fence_sensitive_tables`

  ## What it does

  Fences off the most sensitive ServiceNow tables from two routes that reach
  them:

  1. **The generic Table-API tools** from the michaelbuckner server —
     `perform_query`, `search_records`, `get_record`, and
     `natural_language_search`. These take a **table name** argument and reach
     *any* table the underlying credential can read (`sys_user`,
     `sn_hr_core_*`, `cmdb_ci*`, custom PII/payroll tables), not just
     incidents. The policy denies the call when the requested table is on the
     sensitive list **unless** the caller's IdP groups include the table's
     owner group.

  2. **The fixed-vocabulary user-directory reads** from the echelon-ai-labs
     server — `list_users` and `get_user`. These are the named route to the
     same `sys_user` PII (names, emails, phones, manager chains) that a generic
     `perform_query` on `sys_user` would return, so they are gated behind the
     same groups. Closing the generic route while leaving the named route open
     would be a trivial bypass.

  The sensitive-table mapping is:

  | Table (case-insensitive) | Owner group(s) that may read it |
  |---|---|
  | `sys_user` (exact) | `hr` **or** `infosec` |
  | `sn_hr_core_*` (prefix — HRSD case tables: health/leave/comp) | `hr` |
  | `cmdb_ci*` (prefix — CMDB configuration items) | `infosec` |

  A caller in `hr` may read HR case tables and `sys_user`; a caller in
  `infosec` may read CMDB and `sys_user`; neither may read the other's tables.
  `list_users`/`get_user` require `hr` **or** `infosec` (they surface
  `sys_user`).

  The check runs at ingress, before the call reaches the ServiceNow MCP server,
  so a denied read never executes and no sensitive row is returned.

  ## Fail-closed on an uninspectable query

  A generic Table-API call whose `table` argument is **missing or empty** is
  **denied**, not allowed. Without a table name the query cannot be scoped, so
  there is no way to prove it does not touch a sensitive table — the safe
  default is to reject it and ask the caller to name the table explicitly.

  ## Pin the sensitive-table list to YOUR instance at import time

  The shipped `sensitive_table_groups` / `sensitive_prefix_groups` constants
  are a **documented starter set** covering the standard high-risk tables
  (`sys_user`, `sn_hr_core_*`, `cmdb_ci*`). They are not a complete inventory
  of *your* sensitive data. **At import time, extend the constants with the
  custom PII, payroll, and regulated tables your instance holds** (e.g.
  `u_payroll_*`, `u_ssn_vault`, `sn_hr_core_*` siblings, finance tables) and
  map each to the group that owns it.

  ## Compliance alignment

  - **SOC 2 C1.1** — supports identifying and protecting confidential
    information by keeping sensitive tables (workforce, CMDB) behind owner
    groups; **P4.1** — supports limiting personal-information use to the
    identified purpose by restricting who may pull `sys_user` PII over the
    agent channel.
  - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary and
    role-based-limit standards by gating HRSD case tables
    (`sn_hr_core_*`, which can hold health/leave data) to the `hr` group;
    **§164.308(a)(4)** — supports information-access management by tying table
    access to IdP group membership; **§164.522(a)** — supports agreed-to
    access restrictions as an enforceable predicate on the MCP path.
  - **PCI DSS 7.2.6** — supports restricting programmatic query access to
    stored data by role: the generic Table-API tools are exactly the
    programmatic-query surface, and this policy binds them to owner groups.
  - **GDPR Art. 9** — supports restricting access to special-category data
    (HR/health tables) on the agent channel; **§1798.121 (CPRA)** — supports
    the right to limit use of sensitive personal information by fencing
    `sys_user` and HR tables; **Art. 5(1)(b)** — supports purpose limitation by
    denying broad, unscoped table reads.

  ## Tool name matching

  The DTwo gateway prefixes tool names with the configured MCP server name
  (e.g. `servicenow-mcp-perform_query`), and that prefix is not standardized,
  so the policy matches by **suffix** on `lower(input.resource.name)`:

  - Generic Table-API (any table arg): `*perform_query`, `*search_records`,
    `*get_record`, `*natural_language_search`
  - User directory: `*list_users`, `*get_user`

  Suffix matching covers the two big community servers (echelon-ai-labs and
  michaelbuckner both use `verb_noun` snake_case). If you run a server that uses
  a different convention (e.g. LokiMCPUniverse's `noun_verb`), add its suffixes.
  Verify the exact names your gateway sends with the dump-input debug technique
  before relying on this in production.

  ## Argument shape

  - Generic Table-API tools read the target table from
    `input.payload.args.table` (verified for the michaelbuckner
    `perform_query`/`search_records`/`get_record` README shape). The value is
    lowercased **and whitespace-trimmed** (`trim_space`) before matching, so a
    `"sys_user\n"` / `"sys_user "` variant that ServiceNow might still resolve
    cannot slip past the exact/prefix match. Payload/args are read through
    `object.get` chains, so a call with no `payload` or no `args` at all is
    treated as a missing-table call and fails closed for the generic tools.
  - Caller groups are read from `input.subject.claims.groups` (an array),
    lowercased before comparison.

  ## Examples

  ### Allowed — non-sensitive table

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "servicenow-mcp-perform_query", "type": "tool" },
      "payload": {
        "name": "servicenow-mcp-perform_query",
        "args": { "table": "incident", "query": "active=true" }
      }
    }
  }
  ```

  `allow = true`, no reason.

  ### Allowed — sensitive table, caller in owner group

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "servicenow-mcp-get_record", "type": "tool" },
      "subject": { "claims": { "groups": ["infosec"] } },
      "payload": {
        "name": "servicenow-mcp-get_record",
        "args": { "table": "cmdb_ci_server", "sys_id": "abc123" }
      }
    }
  }
  ```

  `allow = true` — `infosec` owns CMDB.

  ### Denied — sensitive table, no matching group

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "servicenow-mcp-perform_query", "type": "tool" },
      "subject": { "claims": { "groups": ["service-desk"] } },
      "payload": {
        "name": "servicenow-mcp-perform_query",
        "args": { "table": "sys_user", "query": "active=true" }
      }
    }
  }
  ```

  `allow = false`, `reason = "Access to the ServiceNow table 'sys_user' is
  restricted to the hr, infosec group(s). Narrow your query to a non-sensitive
  table, or request membership in one of those groups."`

  ### Denied — user-directory read without a group

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "servicenow-mcp-list_users", "type": "tool" },
      "subject": { "claims": { "groups": [] } },
      "payload": { "name": "servicenow-mcp-list_users", "args": { "limit": 50 } }
    }
  }
  ```

  `allow = false` — `list_users` surfaces `sys_user` PII.

  ## Composition

  This policy is single-purpose (scope fencing). Useful companions on the same
  ServiceNow gateway:

  - [`servicenow/default-deny-unknown-tools`](../default-deny-unknown-tools/policy.md)
    — allowlist the audited tool surface so a renamed/new table tool cannot
    slip past this suffix match.
  - An **egress PII-redaction** policy on `list_incidents` / `get_record` /
    `search_records` / `list_users` responses, so any `sys_user`-shaped PII
    that leaks through a permitted-but-broad read is masked for non-HR/security
    callers (defense in depth against this policy's residual bypasses).

  ## Known limitations

  - **`natural_language_search` argument shape is unverified.** The
    michaelbuckner README documents the tool but not its argument keys. This
    policy treats it as a generic Table-API tool that reads a `table` argument;
    **if it does not carry a `table` argument it fails closed (denied)** under
    the missing-table rule, because an NL query whose target table cannot be
    read is uninspectable and cannot be proven safe. Confirm the real argument
    shape with the dump-input technique; if the tool exposes the table under a
    different key (or infers it server-side), update `table_arg` / the
    missing-table handling accordingly. As shipped, `natural_language_search`
    is effectively blocked unless it carries an inspectable `table` argument.
  - **Group names are placeholders — replace `hr` and `infosec` with your
    IdP's group names at import time.** They are read from
    `input.subject.claims.groups`; if your IdP emits groups under a
    different claim (e.g. a namespaced `https://acme.com/groups`) or a
    non-array shape, adjust `caller_groups`. If the gateway has no IdP
    configured, `groups` is absent and every sensitive read fails closed.
  - **Sensitive list is not exhaustive.** Only `sys_user`, `sn_hr_core_*`, and
    `cmdb_ci*` ship by default. Custom PII/payroll tables (`u_*`) are not fenced
    until you add them to the constants — see "Pin the sensitive-table list"
    above.
  - **`sys_user` is matched exactly, so its sibling `sys_user_*` tables are not
    fenced.** The mapping keys `sys_user` as an exact name (not a prefix), which
    is deliberate — the crown-jewel contact PII (names, emails, phones, manager
    chains) lives in `sys_user` itself. But the standard `sys_user_*` family holds
    related workforce identity and access data that some instances treat as
    equally sensitive: `sys_user_group`, `sys_user_grmember` (group membership —
    ACL reconnaissance), `sys_user_role`, `sys_user_has_role`, and
    `sys_user_preference`. These are **not** on the sensitive list, so an
    ungrouped caller can read them through `perform_query`/`get_record`/
    `search_records`. If your instance treats the family as sensitive, add
    `"sys_user"` to `sensitive_prefix_groups` (mapped to `{"hr", "infosec"}`) to
    fence the whole family, or list specific siblings in `sensitive_table_groups`.
    Prefixing `sys_user` will also fence benign lookups some service-desk
    workflows rely on (e.g. reading `sys_user_group` for ticket routing), so
    choose per your instance. (Red-team confirmed: `perform_query` on
    `sys_user_grmember` by an ungrouped caller is allowed as shipped.)
  - **Row-level scoping is coarse.** The policy fences by table name, not by
    row. A caller in `hr` who may read `sys_user` can read *all* of it; use an
    egress redaction policy if you need per-field or per-row limits.
  - **Dot-walk / field-selection exfiltration through a permitted table is not
    caught.** A `perform_query`/`get_record` on an allowed table (e.g.
    `incident`) can pull referenced `sys_user` fields with a dot-walked query or
    field list (`sysparm_fields=caller_id.email,caller_id.phone`,
    `caller_id.user_name=…`). The policy fences on the *target* table name only;
    it cannot see PII columns reached by reference. This is the primary residual
    — pair the egress PII-redaction companion below so any `sys_user`-shaped data
    that comes back through a permitted read is masked for non-HR/security
    callers. (Red-team confirmed: `table:"incident"` with a
    `caller_id.email` field selection is allowed.)
  - **The michaelbuckner server also exposes tables as MCP _resources_
    (`servicenow://tables/{table}`), not just tools.** This policy matches tool
    suffixes on `tool_pre_invoke`; a resource read of
    `servicenow://tables/sys_user` does **not** end with a guarded suffix and
    passes through. If your gateway proxies MCP resource reads through policy,
    add a companion rule that fences resource URIs, or disable the resource
    surface on the server. (Red-team confirmed: a `servicenow://tables/sys_user`
    resource read is allowed by this policy.)
  - **Other reach paths exist.** ServiceNow PII is also reachable through
    incident `description`/`comment` bodies, HRSD Now Assist skills on the
    official server, and Knowledge Graph queries — none of which take a `table`
    argument. This policy only fences the generic Table-API and the named
    user-directory reads; compose the egress redaction companion for the rest.

  > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it.
direction: ingress
apps:
  - servicenow
industries: []
bundles:
  - soc2
  - hipaa
  - pci-dss
  - gdpr-ccpa
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```rego
package servicenow.ingress.fence_sensitive_tables

# Deny-by-default: only the explicit allow rules below permit the request.
default allow := false

# --- Tool surfaces we guard (matched by suffix for gateway-prefix portability) ---

# Generic Table-API tools (michaelbuckner). Each takes a `table` argument and
# can reach ANY table the credential can read.
generic_table_tool_suffixes := {
    "perform_query",
    "search_records",
    "get_record",
    "natural_language_search",
}

# Fixed-vocabulary user-directory reads (echelon-ai-labs). These surface the
# same sys_user PII, so they are gated behind the same groups as sys_user.
user_directory_tool_suffixes := {
    "list_users",
    "get_user",
}

# --- Sensitive-table mapping (documented starter set; tenants EXTEND these) ---

# Exact sensitive table names -> the set of groups permitted to read them.
sensitive_table_groups := {
    "sys_user": {"hr", "infosec"},
}

# Sensitive table-name PREFIXES -> the set of groups permitted to read them.
# `sn_hr_core_` = HRSD case tables (health/leave/comp); `cmdb_ci` = CMDB items.
sensitive_prefix_groups := {
    "sn_hr_core_": {"hr"},
    "cmdb_ci": {"infosec"},
}

# Groups permitted to use the named user-directory reads (they surface sys_user).
user_directory_groups := {"hr", "infosec"}

# --- Tool classification ---

is_generic_table_tool if {
    some suffix in generic_table_tool_suffixes
    endswith(lower(input.resource.name), suffix)
}

is_user_directory_tool if {
    some suffix in user_directory_tool_suffixes
    endswith(lower(input.resource.name), suffix)
}

# --- Identity: caller's IdP groups, lowercased. Missing claims -> empty set. ---

claims := object.get(object.get(input, "subject", {}), "claims", {})

caller_groups := {lower(g) | some g in object.get(claims, "groups", [])}

# --- Requested table (case-insensitive, whitespace-trimmed) ---

# Safe args access: missing payload/args -> {} (no direct index of input.payload.args).
args := object.get(object.get(input, "payload", {}), "args", {})

# trim_space closes a whitespace-evasion bypass: "sys_user\n" / "sys_user "
# would otherwise miss the exact/prefix match yet may still resolve server-side.
table_arg := trim_space(lower(object.get(args, "table", "")))

table_arg_present if {
    table_arg != ""
}

# The union of every owner-group set that matches the requested table
# (exact match on sys_user, or prefix match on sn_hr_core_/cmdb_ci).
table_required_groups := union(matched_group_sets)

matched_group_sets := {groups |
    some name, groups in sensitive_table_groups
    name == table_arg
} | {groups |
    some prefix, groups in sensitive_prefix_groups
    startswith(table_arg, prefix)
}

table_is_sensitive if {
    count(table_required_groups) > 0
}

caller_authorized_for_table if {
    some g in table_required_groups
    caller_groups[g]
}

caller_authorized_for_directory if {
    some g in user_directory_groups
    caller_groups[g]
}

# --- Allow rules ---

# Any tool we don't guard passes through untouched.
allow if {
    not is_generic_table_tool
    not is_user_directory_tool
}

# Generic Table-API call on a non-sensitive table (with a table named).
allow if {
    is_generic_table_tool
    table_arg_present
    not table_is_sensitive
}

# Generic Table-API call on a sensitive table by an owner-group member.
allow if {
    is_generic_table_tool
    table_arg_present
    table_is_sensitive
    caller_authorized_for_table
}

# Named user-directory read by an hr/infosec member.
allow if {
    is_user_directory_tool
    caller_authorized_for_directory
}

# --- Deny reasons ---

# Fail closed: generic tool with no table argument cannot be scoped.
reasons contains sprintf("The ServiceNow tool '%s' was called without a 'table' argument, so the query cannot be scoped to a non-sensitive table. Name the specific table you are authorized to read.", [lower(input.resource.name)]) if {
    is_generic_table_tool
    not table_arg_present
}

# Sensitive table, caller lacks the owner group.
reasons contains sprintf("Access to the ServiceNow table '%s' is restricted to the %s group(s). Narrow your query to a non-sensitive table, or request membership in one of those groups.", [table_arg, concat(", ", sort([g | some g in table_required_groups]))]) if {
    is_generic_table_tool
    table_arg_present
    table_is_sensitive
    not caller_authorized_for_table
}

# Named user-directory read, caller lacks hr/infosec.
reasons contains "Access to the ServiceNow user directory (the sys_user table: names, emails, phones, manager chains) is restricted to the hr or infosec group. Narrow your query to a non-sensitive lookup, or request membership in one of those groups." if {
    is_user_directory_tool
    not caller_authorized_for_directory
}

reason := joined if {
    count(reasons) > 0
    reason_list := sort([r | some r in reasons])
    joined := concat("; ", reason_list)
}
```
