---
name: Fence Sensitive monday Boards by IdP Group
tags:
  - monday
  - fence-sensitive-scopes
  - ingress
  - soc2
  - gdpr-ccpa
publishedAt: 2026-07-12
description: |
  # monday / fence-sensitive-boards

  **Direction:** ingress (`tool_pre_invoke`)
  **Default:** deny fenced targets for callers outside the mapped group, allow otherwise
  **Package:** `monday.ingress.fence_sensitive_boards`

  ## What it does

  monday boards are schemaless business databases: HR/recruiting boards (candidate PII), CRM/deal
  boards (financial), and IT/security trackers routinely live in the same account behind a single
  flat API token. Sensitivity is a property of the **board / workspace ID**, not the tool. This
  policy converts that flat single-token scope into per-team least-privilege scoping by pinning
  sensitive board and workspace IDs to the IdP group required to touch them.

  The policy carries two placeholder maps — `fenced_boards` and `fenced_workspaces` — that pair a
  monday board/workspace ID with a group (e.g. board `1111111111` → `hr`, CRM board → `sales`,
  security-tracker board → `infosec`). At ingress it reads the target scope from `boardId`,
  `boardIds[]`, and `workspaceIds[]` on the inspected tools and denies the call when a requested ID
  is in a fenced set and the caller lacks the mapped group. Inspected tools:

  - **Reads** — `get_board_items_page`, `get_full_board_data`, `board_insights`, `get_updates`,
    `get_board_activity`, `read_docs`, `fetch_file_content`, and `search`.
  - **Writes** — `create_item`, `create_items` (batch), and `change_item_column_values`.

  `search` is special: it is monday's account-wide discovery surface. A `search` call that carries
  **no** `boardIds` and **no** `workspaceIds` filter is treated as account-wide discovery and is
  denied for non-privileged callers, so it cannot be used to enumerate around the fence. A scoped
  `search` is allowed only when none of its `boardIds`/`workspaceIds` are fenced away from the
  caller; an unscoped `search` is reserved for a caller who holds **every** fenced group (they could
  reach any fenced board anyway).

  Group membership is read from `input.subject.claims.groups` via `object.get` chains and fails
  closed: a missing, empty, or malformed `groups` claim never grants access to a fenced target — no
  group means not permitted. All tools this policy does not inspect pass through untouched.

  ## Compliance alignment

  - **SOC 2 C1.1** — supports identification and protection of confidential information by gating
    agent access to designated confidential boards to their mapped groups; **P4.1** — supports
    limiting personal-information use to identified purposes by keeping PI-bearing boards (HR/CRM)
    behind role fences on the agent channel.
  - **HIPAA §164.502(b) / §164.514(d)** — supports the minimum-necessary / role-based-limit
    standard by scoping agent access to PHI-bearing boards to the mapped group;
    **§164.308(a)(4)** — supports information access management: access to sensitive boards is
    authorized by IdP group on the MCP path; **§164.522(a)** — fenced board IDs can encode
    agreed-to restrictions on specific record sets.
  - **PCI DSS 7.2.6** — supports restricting programmatic query access to stored cardholder data by
    role: fence the CRM/deal or finance board so only the mapped group can pull it through an agent.
  - **GDPR Art. 9** — supports special-category protection by fencing boards holding health, HR, or
    other Art. 9 data; **Art. 5(1)(b)** — supports purpose limitation by keeping sensitive boards
    scoped to the team whose purpose they serve; **CPRA §1798.121** — supports the right to limit
    use of sensitive personal information by fencing SPI boards to a minimal group.

  ## Tool name matching

  Tool names are matched case-insensitively as an exact name or by `-`/`_`-separated suffix, so the
  policy tolerates any gateway server-name prefix (e.g. `monday-mcp_get_board_items_page`). The
  official monday server (`https://mcp.monday.com/mcp`) exposes these tools **unprefixed**:

  - Reads: `get_board_items_page`, `get_full_board_data`, `board_insights`, `get_updates`,
    `get_board_activity`, `read_docs`, `fetch_file_content`, `search`.
  - Writes: `create_item`, `create_items`, `change_item_column_values`.

  Suffix matching also catches the community `sakce/mcp-server-monday` `monday_`-prefixed spellings.
  Some share a suffix with the official names (`monday_create_item` matches `create_item`), but the
  sakce write-update and its board-content read are named differently and do **not** overlap, so the
  policy matches them explicitly: `update_item` (sakce's coarser equivalent of official
  `change_item_column_values`) and `list_items_in_groups` (its board-content read). That server's
  tool set is otherwise coarser and does not expose `board_insights`, `search`, `read_docs`, or
  `fetch_file_content`; its remaining reads are item-ID-scoped and cannot be fenced by board ID (see
  Known limitations). Verify the exact names your gateway sends with the dump-input debug technique
  before relying on this in production.

  ## Argument shape

  - `get_board_items_page`, `get_full_board_data`, `board_insights`, `create_item`,
    `change_item_column_values`: scalar `boardId` (number or string).
  - `search`: `boardIds[]` and/or `workspaceIds[]` (arrays); absent → account-wide.
  - IDs are normalized to a trimmed string, so numeric and string encodings both match.

  The policy also reads a `boardIds[]` array and a `workspaceIds[]` array on **every** inspected
  tool defensively, so a tool that carries the scope under those keys is fenced the same way. See
  Known limitations for `read_docs` / `fetch_file_content`, whose real schemas name their targets
  differently.

  ## Examples

  ### Allowed

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "monday-mcp_get_board_items_page", "type": "tool" },
      "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } },
      "payload": {
        "name": "monday-mcp_get_board_items_page",
        "args": { "boardId": 9999999999 }   // not in fenced_boards
      }
    }
  }
  ```

  `allow = true`, no reason.

  ### Denied

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "monday-mcp_get_board_items_page", "type": "tool" },
      "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } },
      "payload": {
        "name": "monday-mcp_get_board_items_page",
        "args": { "boardId": 1111111111 }   // fenced_boards → hr
      }
    }
  }
  ```

  `allow = false`, `reason = "monday board 1111111111 is fenced as sensitive and requires the 'hr' IdP group. (...)"`.

  ## Composition

  This policy is single-purpose: it fences reads/writes/search of pinned sensitive board and
  workspace IDs. Useful companions:

  - **`apps/monday/deny-graphql-escape-hatch`** (or equivalent) — deny `all_monday_api` /
    `all_api_read` / `all_api_write` / `manage_tools`. Without it, every fence here is bypassable
    via one raw GraphQL `query` string (see Known limitations).
  - An **egress PII/PHI redaction** policy on `get_board_items_page`, `get_full_board_data`,
    `read_docs`, and `get_updates` responses, to mop up regulated values that an authorized caller
    reads back (and to cover the reads this ingress policy does not scope by ID).
  - A **directory guard** denying `list_users_and_teams` for non-admin callers, since it returns
    account-wide names/emails independent of any board fence.

  ## Known limitations

  - **Literal, canonical-ID matching only.** The policy matches the exact board/workspace IDs in its
    maps. A board reached by an ID not on the list is not fenced. IDs are compared as trimmed strings
    after `sprintf` normalization of numbers, so `1111111111` (number) and `"1111111111"` (string)
    both match — but a non-canonical spelling that monday still resolves (e.g. a leading-zero
    `"01111111111"`, or an ID carrying surrounding formatting the API tolerates) will **not** match
    the fence key. Pin every sensitive board's exact canonical ID (and the enclosing workspace ID) at
    import time; the shipped IDs are placeholders.
  - **`read_docs` and `fetch_file_content` scope by item, not board.** In the real monday schema
    `read_docs` targets `ids[]` (+ a `type` of `ids|object_ids|workspace_ids`) and
    `fetch_file_content` targets `item_id` + `column_id` — neither carries a `boardId`. This policy
    fences them only when a `boardId` / `boardIds[]` / `workspaceIds[]` scope key is present (which
    `read_docs` does supply when `type` is `workspace_ids`). A doc or file fetched by a bare
    item/object ID is **not** fenced by this policy. Pair with the egress redaction companion, and
    fence the workspace IDs so `read_docs` with `type: workspace_ids` is caught.
  - **`create_items` (batch) arg shape is unverified.** The batch-create tool is fenced the
    same way as `create_item`, and the policy reads a board ID both from a top-level `boardId`
    and from a per-item `boardId` inside an `items[]` array. monday's exact `create_items` schema
    was not verified against source; if your server nests the board target under a different key
    (or accepts a `boardIds[]` on the batch call), confirm with the dump-input debug technique and
    extend `requested_board_ids`. A batch write that names no board ID the policy can see is not
    fenced (same residual as the account-wide-read limitation below).
  - **GraphQL escape hatch bypasses this policy.** `all_monday_api` / `all_api_read` /
    `all_api_write` reduce every board read/write to one opaque GraphQL string with no `boardId`
    argument to inspect. This policy does not cover them — attach the escape-hatch deny companion
    (see Composition), or every fence here is defeatable.
  - **Account-wide reads that name no board are not fenced.** `get_full_board_data` and friends are
    only fenced by the IDs supplied; a broad discovery path that returns a board without ever
    passing its ID as an argument cannot be caught statelessly. The `search` unscoped-discovery
    deny is the guard for the main enumeration surface; other account-wide readers should be paired
    with egress redaction. In particular, a board-scoped tool called with **no** board/workspace ID
    argument at all (e.g. a malformed or exploratory `get_board_items_page` with empty args) names
    no fenced target and so passes through — the fence only fires on a fenced ID it can see.
  - **Not every board-reading tool is inspected.** This policy fences the content-bearing reads
    (`get_board_items_page`, `get_full_board_data`, `board_insights`, `get_updates`,
    `get_board_activity`, `read_docs`, `fetch_file_content`). Other `boardId`-scoped readers on the
    official server — `get_board_info`, `get_board_schema`, `get_assets`, `fetch_custom_activity`,
    and the monday-dev sprint readers — are **not** fenced, so a caller can still learn a fenced
    board's structure/metadata or list its assets. Add the ones that matter for your data model to
    `is_board_scope_tool`, and rely on the egress redaction companion for the content itself.
  - **Workspace-scoped `search` can still cross the board fence.** A scoped `search` is allowed when
    none of its `boardIds`/`workspaceIds` are themselves fenced. If a fenced board lives inside a
    workspace that is **not** in `fenced_workspaces`, a `search` scoped to that (unfenced) workspace
    reaches the fenced board's items. Fence the **enclosing workspace ID** of every fenced board (add
    it to `fenced_workspaces`) so workspace-scoped discovery is caught too.
  - **Unscoped `search` is reserved for fully-privileged callers.** A caller holding every fenced
    group may run an unscoped account-wide `search`. If even that is unacceptable, tighten the
    unscoped-search allow rule to deny outright.
  - **`groups` claim must be an array of strings.** A string-valued or otherwise malformed claim
    fails closed (fenced targets deny). If your IdP emits groups under a different claim name (e.g.
    a namespaced custom claim), update `caller_groups` in the Rego.
  - **Community (sakce) item-ID-scoped tools are not fenced.** The policy fences the two sakce
    board-ID-scoped tools (`monday_update_item`, `monday_list_items_in_groups`). Its other sakce
    tools — `monday_get_items_by_id`, `monday_get_item_updates`, `monday_list_subitems_in_items`,
    and `monday_create_update` — address items by `itemId` with no board argument, so a stateless
    ingress policy cannot map them to a fenced board and they pass through. `monday_get_board_groups`
    / `monday_get_board_columns` return board structure/metadata and are treated like the official
    metadata readers below (uninspected). Pair with the egress redaction companion.
  - **A decoy scope downgrades the account-wide `search` guard.** The unscoped-`search` deny fires
    only when a call carries **no** `boardIds`/`workspaceIds` filter. A caller can make an otherwise
    account-wide search count as "scoped" by adding any non-fenced (even nonexistent) board ID, e.g.
    `boardIds: [1]`. This is allowed — but because a scoped search only returns the boards it names,
    a decoy ID reaches only that decoy board's data, not any fenced board. The guard stops blind
    account-wide enumeration, not a search a caller deliberately narrows to boards they may access.
  - **Placeholder configuration.** Board IDs, workspace IDs, and group names (`hr`, `sales`,
    `infosec`) are placeholders — replace them with your deployment's real monday IDs and IdP group
    names at import time.

  > **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:
  - monday
industries: []
bundles:
  - soc2
  - gdpr-ccpa
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```rego
package monday.ingress.fence_sensitive_boards

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

# ---------------------------------------------------------------------------
# Fence configuration — PLACEHOLDERS, replace at import time.
#
# monday sensitivity is a property of the board/workspace ID, not the tool.
# Pin each sensitive board's ID (and the enclosing workspace ID) to the IdP
# group required to touch it. Group names are compared case-insensitively
# against the caller's `groups` claim.

# monday board ID -> IdP group required to read/write/search it.
fenced_boards := {
    "1111111111": "hr",      # e.g. Recruiting / candidate pipeline board
    "2222222222": "sales",   # e.g. CRM / deal board
    "3333333333": "infosec", # e.g. Security-incident tracker board
}

# monday workspace ID -> IdP group required to read/search inside it.
fenced_workspaces := {
    "4444444444": "hr",      # e.g. People-ops workspace
    "5555555555": "infosec", # e.g. Security workspace
}

# ---------------------------------------------------------------------------
# Identity — read groups via object.get chains so a missing subject/claims/
# groups fails closed (no group -> no access to fenced targets).

caller_groups := object.get(object.get(object.get(input, "subject", {}), "claims", {}), "groups", [])

# True when the caller's groups claim (an array of strings) contains `group`.
# A malformed (non-array) claim makes the iteration fail -> fail closed.
caller_has_group(group) if {
    some g in caller_groups
    lower(g) == lower(group)
}

# Every distinct group referenced by the fence maps. A caller holding all of
# them may run unscoped account-wide searches (they could reach any board).
fence_groups contains group if {
    some _, group in fenced_boards
}

fence_groups contains group if {
    some _, group in fenced_workspaces
}

privileged_search_caller if {
    every group in fence_groups {
        caller_has_group(group)
    }
}

# ---------------------------------------------------------------------------
# Tool matching. The gateway prefixes tool names with the configured MCP
# server name (separator not standardized), so match the exact name or a
# `-`/`_`-separated suffix, case-insensitively. Verify the exact names your
# gateway sends with the dump-input debug technique.

tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

tool_matches(suffix) if {
    tool_name == suffix
}

tool_matches(suffix) if {
    endswith(tool_name, sprintf("-%s", [suffix]))
}

tool_matches(suffix) if {
    endswith(tool_name, sprintf("_%s", [suffix]))
}

# Board-scoped reads and writes (scope arrives on boardId / boardIds[] /
# workspaceIds[]).
is_board_scope_tool if tool_matches("get_board_items_page")

is_board_scope_tool if tool_matches("get_full_board_data")

is_board_scope_tool if tool_matches("board_insights")

# get_updates returns item comments/updates (free-text, the richest PII surface
# on a board) and get_board_activity returns the board's change log; both are
# scoped by `boardId` in the monday schema, so a caller denied
# get_board_items_page could otherwise read the same fenced board's content
# through them. Fence them the same way. (Arg-shape unverified — see Known
# limitations; if either omits boardId the call passes through untouched.)
is_board_scope_tool if tool_matches("get_updates")

is_board_scope_tool if tool_matches("get_board_activity")

is_board_scope_tool if tool_matches("read_docs")

is_board_scope_tool if tool_matches("fetch_file_content")

is_board_scope_tool if tool_matches("create_item")

# Batch create. Distinct suffix from `create_item`, so it must be matched
# explicitly or the write fence is bypassable by creating items in bulk.
is_board_scope_tool if tool_matches("create_items")

is_board_scope_tool if tool_matches("change_item_column_values")

# Community sakce/mcp-server-monday board-scoped tools. Their suffixes do NOT
# overlap the official names, so suffix matching alone misses them: the sakce
# write-update tool is `monday_update_item` (verified boardId/itemId/columnValues
# args; coarser equivalent of official change_item_column_values) and its
# board-content read is `monday_list_items_in_groups` (takes a boardId). Match
# both so a sakce deployment's fenced-board write/read is not a free bypass.
# (list_items_in_groups arg shape is unverified — if it omits boardId the call
# passes through untouched, same posture as get_updates. sakce item-id-scoped
# tools — get_items_by_id, get_item_updates, list_subitems_in_items,
# create_update — carry no boardId and cannot be fenced statelessly; see Known
# limitations.)
is_board_scope_tool if tool_matches("update_item")

is_board_scope_tool if tool_matches("list_items_in_groups")

# Account-wide discovery surface.
is_search_tool if tool_matches("search")

# Any tool this policy inspects.
is_fenced_scope_tool if is_board_scope_tool

is_fenced_scope_tool if is_search_tool

# ---------------------------------------------------------------------------
# Argument extraction — object.get everywhere; monday IDs may arrive as
# numbers or strings, so normalize both to a trimmed string.

args := object.get(object.get(input, "payload", {}), "args", {})

to_id(x) := trim_space(x) if is_string(x)

to_id(x) := sprintf("%v", [x]) if is_number(x)

# Requested board IDs: scalar `boardId` plus a `boardIds[]` array (checked on
# every inspected tool defensively).
requested_board_ids contains id if {
    id := to_id(object.get(args, "boardId", ""))
    id != ""
}

requested_board_ids contains id if {
    some raw in object.get(args, "boardIds", [])
    id := to_id(raw)
    id != ""
}

# Batch tools (e.g. create_items) may carry a per-item boardId inside an
# `items[]` array rather than a top-level `boardId`. Pull those too so a bulk
# write cannot slip a fenced board past the top-level scalar check. `items`
# schema for create_items is unverified — see Known limitations.
requested_board_ids contains id if {
    some item in object.get(args, "items", [])
    id := to_id(object.get(item, "boardId", ""))
    id != ""
}

# Requested workspace IDs: a `workspaceIds[]` array.
requested_workspace_ids contains id if {
    some raw in object.get(args, "workspaceIds", [])
    id := to_id(raw)
    id != ""
}

# For search, "scoped" means at least one board or workspace filter is present.
search_scope_count := count(requested_board_ids) + count(requested_workspace_ids)

# ---------------------------------------------------------------------------
# Fence checks.

blocked_board if {
    some id in requested_board_ids
    group := object.get(fenced_boards, id, "")
    group != ""
    not caller_has_group(group)
}

blocked_workspace if {
    some id in requested_workspace_ids
    group := object.get(fenced_workspaces, id, "")
    group != ""
    not caller_has_group(group)
}

# ---------------------------------------------------------------------------
# Allow rules.

# Any tool this policy does not inspect passes through untouched.
allow if {
    not is_fenced_scope_tool
}

# Board-scoped reads/writes: allowed when no requested board/workspace ID is
# fenced away from the caller (or none is fenced at all).
allow if {
    is_board_scope_tool
    not blocked_board
    not blocked_workspace
}

# Scoped search: allowed when a board/workspace filter is present and none of
# its IDs are fenced away from the caller.
allow if {
    is_search_tool
    search_scope_count > 0
    not blocked_board
    not blocked_workspace
}

# Unscoped (account-wide) search can surface content from any fenced board, so
# it is reserved for callers holding every fenced group.
allow if {
    is_search_tool
    search_scope_count == 0
    privileged_search_caller
}

# ---------------------------------------------------------------------------
# Deny reasons.

reasons contains msg if {
    is_fenced_scope_tool
    some id in requested_board_ids
    group := object.get(fenced_boards, id, "")
    group != ""
    not caller_has_group(group)
    msg := sprintf("monday board %s is fenced as sensitive and requires the '%s' IdP group. Ask your monday admin for access, or contact InfoSec if this fence looks wrong.", [id, group])
}

reasons contains msg if {
    is_fenced_scope_tool
    some id in requested_workspace_ids
    group := object.get(fenced_workspaces, id, "")
    group != ""
    not caller_has_group(group)
    msg := sprintf("monday workspace %s is fenced as sensitive and requires the '%s' IdP group. Ask your monday admin for access, or contact InfoSec if this fence looks wrong.", [id, group])
}

reasons contains "Account-wide monday search is restricted while sensitive boards are fenced. Re-run the search with boardIds or workspaceIds scoped to boards you may access, or ask your monday admin for the fenced groups." if {
    is_search_tool
    search_scope_count == 0
    not privileged_search_caller
}

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