---
name: Confine Airtable Agent to Allowlisted Bases
tags:
  - airtable
  - fence-sensitive-scopes
  - ingress
  - soc2
  - gdpr-ccpa
publishedAt: 2026-07-12
description: |
  # airtable / fence-base-allowlist

  **Direction:** ingress (`tool_pre_invoke`)
  **Default:** deny base-scoped calls unless the `baseId` is on the allowlist; allow discovery and non-base tools
  **Package:** `airtable.ingress.fence_base_allowlist`

  ## What it does

  An Airtable OAuth grant (or Personal Access Token) with the `workspacesAndBases:read`
  scope spans the **entire** workspace — every base the connected identity can see, not just the
  ones an operator intends the agent to touch. Sensitivity in Airtable is a property of the
  **base** (`app…`), which routinely holds CRM contacts, applicant-tracking pipelines,
  customer/financial trackers, and — on HIPAA-eligible Enterprise plans — health-ops data.

  This policy converts that workspace-wide grant into per-base least privilege by pinning an
  operator-maintained **allowlist of sanctioned base IDs**. At ingress it reads the `baseId`
  argument from every record, schema, and page tool and denies the call unless that `baseId`
  is on the allowlist. The allowlist (`allowed_bases`) is a per-tenant constant pinned at import
  time — the shipped IDs are placeholders.

  Base-scoped tools inspected (both the official server's verbose `*_for_table` / `*_for_page`
  spellings and the community servers' terse names):

  - **Record reads** — `list_records*` (incl. `list_records_for_page`), `search_records`,
    `get_record*` (incl. `get_record_for_page`), and the official `display_records_for_table`
    interactive widget (disabled by default, but fenced if enabled).
  - **Record writes** — `create_record*`, `update_records*`.
  - **Schema writes** — `create_table`, `update_table`, `create_field`, `update_field`.

  Discovery tools that carry **no** `baseId` — `ping`, `list_bases`, `search_bases`,
  `list_workspaces` — are left untouched, so the agent can still enumerate what exists; but any
  operation targeting a specific base must name an allowlisted `app…` ID.

  Every field access uses `object.get`, so the fence **fails closed**: a base-scoped tool call
  that supplies no `baseId` (or carries it under an unexpected key) resolves to the empty string,
  which is not in the allowlist, and is denied. The default is `deny`; only the two explicit allow
  rules below permit a request.

  ## Compliance alignment

  - **SOC 2 C1.1** — supports identification and protection of confidential information by
    confining agent access to a governed set of bases on the MCP path; **P4.1** — supports
    limiting personal-information use to identified purposes by keeping PI-bearing bases
    (CRM / ATS) off the agent path unless explicitly sanctioned.
  - **GDPR Art. 9** — supports special-category protection by keeping bases holding health, HR, or
    other Art. 9 data off the agent path until sanctioned; **Art. 5(1)(b)** — supports purpose
    limitation by confining the agent to bases whose purpose the operator has approved;
    **CPRA §1798.121** — supports the right to limit use of sensitive personal information by
    fencing SPI-bearing bases to a minimal allowlist.

  ## Tool name matching

  The gateway prefixes tool names with the configured MCP server name (e.g.
  `airtable-list_records_for_table`), and that prefix is not standardized. This policy matches
  **case-insensitively by substring** on a small set of canonical stems (`list_record`,
  `display_record`, `search_record`, `get_record`, `create_record`, `update_record`,
  `create_table`, `update_table`, `create_field`, `update_field`). Substring matching is deliberate here: it tolerates any server
  prefix **and** covers both spellings the ecosystem uses —

  - the **official** remote server's verbose names (`list_records_for_table`,
    `list_records_for_page`, `get_record_for_page`, `display_records_for_table`,
    `create_records_for_table`,
    `update_records_for_table`, `create_table`, `update_table`, `create_field`, `update_field`),
    verified against the [Airtable support doc](https://support.airtable.com/docs/using-the-airtable-mcp-server); and
  - the **community** servers' terse names (`list_records`, `search_records`, `get_record`,
    `create_record`, `update_records`, `create_table`, `update_table`, `create_field`,
    `update_field`), verified from the [domdomegg README](https://github.com/domdomegg/airtable-mcp-server).

  Verify the exact names your gateway sends with the dump-input debug technique before relying on
  this in production. If your Airtable MCP server exposes other base-scoped tools, add their stems
  to `base_scoped_stems`.

  ## Argument shape

  Every record, schema, and page tool carries the target base as a scalar string `baseId`
  (`app…`). The policy reads it with `object.get(args, "baseId", "")` and compares it **verbatim,
  case-sensitively**, against `allowed_bases` — Airtable base IDs are case-sensitive, so the
  allowlist is not lower-cased. A call that omits `baseId`, or carries it under a different key,
  yields `""` and is denied (fail closed).

  ## Examples

  ### Allowed — operation on a sanctioned base

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "airtable-list_records_for_table", "type": "tool" },
      "payload": {
        "name": "airtable-list_records_for_table",
        "args": { "baseId": "appEXAMPLEBASE0001", "tableId": "tbl123" }  // on the allowlist
      }
    }
  }
  ```

  `allow = true`, no reason.

  ### Allowed — discovery tool carrying no baseId

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "airtable-list_bases", "type": "tool" },
      "payload": { "name": "airtable-list_bases", "args": {} }
    }
  }
  ```

  `allow = true`, no reason.

  ### Denied — operation on a base that is not allowlisted

  ```jsonc
  {
    "input": {
      "action": "tool_pre_invoke",
      "resource": { "name": "airtable-list_records_for_table", "type": "tool" },
      "payload": {
        "name": "airtable-list_records_for_table",
        "args": { "baseId": "appUNSANCTIONED99", "tableId": "tbl123" }  // not on the allowlist
      }
    }
  }
  ```

  `allow = false`, `reason = "Airtable base appUNSANCTIONED99 is not on the sanctioned-base allowlist, so the agent may not operate on it. (...)"`.

  ## Composition

  This policy is single-purpose: it confines base-scoped calls to an allowlist of sanctioned
  `app…` IDs. Useful companions:

  - **`apps/airtable/freeze-destructive-ops`** (or equivalent) — deny community `delete_records`.
    This allowlist policy does **not** inspect deletes (see Known limitations), so a delete against
    a non-allowlisted base is not caught here.
  - A **schema-freeze** policy denying `create_base`, `create_interface`, `create_page`,
    `publish_interface`, and `upload_attachment` for everyone but a builders group — this policy
    does not fence `create_base` (it names a `workspaceId`, not a `baseId`) or the interface tools.
  - An **egress PII/PHI redaction** policy on `list_records*` / `search_records` / `get_record*`
    responses, so regulated values read back from an *allowlisted* base are still masked.

  ## Known limitations

  - **Base allowlist only — not a per-table/record fence.** The policy governs *which bases* the
    agent may touch, not which tables or records inside them. Once a base is allowlisted, every
    table/record in it is reachable. Pair with an egress redaction companion for field-level
    control.
  - **Literal, canonical-ID matching.** `baseId` is compared verbatim and case-sensitively against
    `allowed_bases`. A base reached by an ID not on the list is denied (the intended default-deny),
    but this also means the allowlist must contain each sanctioned base's exact canonical `app…`
    ID. The shipped IDs (`appEXAMPLEBASE0001`, `appEXAMPLEBASE0002`) are placeholders — replace them
    with your tenant's real base IDs at import time.
  - **Only the enumerated base-scoped tools are fenced.** Other tools that also carry a `baseId`
    but are **not** in the enumerated set pass through untouched — notably `list_tables_for_base`,
    `get_table_schema` / `describe_table`, `list_pages_for_base`, `describe_page_element`,
    `describe_page_type`, `list_comments` / `create_comment`, community `delete_records`, and
    `upload_attachment`. A caller can still
    enumerate a non-allowlisted base's table/page structure, read or post comments on it, or (on a
    community server) delete its records through these. Add the stems that matter for your data model to `base_scoped_stems`,
    and attach the destructive-ops / schema-freeze companions above.
  - **`create_base` is not fenced.** Base creation names a `workspaceId`, not a `baseId`, so it is
    outside this policy's model; a newly created base is also, by construction, not yet on the
    allowlist, so subsequent record operations against it are denied — but the creation itself is
    not blocked here. Use the schema-freeze companion to gate `create_base`.
  - **Webhook / persistent-channel tools are not fenced (and survive the session).** Airtable's
    webhook API is per-base (`POST /bases/{baseId}/webhooks`), so a webhook-management tool carries
    an explicit `baseId` yet its name contains **none** of the enumerated record/schema stems — it
    therefore passes through the non-base-scoped allow branch even when the `baseId` is *not*
    allowlisted. The 42-tool `rashidazarang/airtable-mcp` community server exposes such webhook tools
    (individual names unverified in the landscape note); the note flags them as a standout risk
    because a webhook creates an outbound data channel that persists after the MCP session ends. This
    policy does **not** stop an agent from registering a webhook on a non-allowlisted base and
    exfiltrating its changes continuously. Deny webhook-creation and other persistence tools with a
    dedicated `deny-escape-hatches` / mailbox-persistence-style companion, and pin the tool inventory
    with a `default-deny-unknown-tools` companion so new/renamed upstream tools fail closed.
  - **No raw-API escape-hatch coverage.** If your Airtable MCP server exposes a generic
    pass-through/GraphQL tool that carries the base target inside an opaque query string rather than
    a `baseId` argument, this policy cannot see it. Deny such tools with a separate escape-hatch
    policy.
  - **Substring tool matching.** Matching is by substring on canonical stems to cover the official
    `_for_table`/`_for_page` infixes, the terse community names, and any gateway prefix. In the
    unlikely event your gateway server name itself contains one of these stems, a discovery tool
    could be mis-classified as base-scoped; verify the exact tool names your gateway sends with the
    dump-input debug technique.
  - **Stems are underscore-delimited — a different word separator is not matched (fail-open, not
    fail-closed).** The stems (`list_record`, `get_record`, …) assume the snake_case spelling used by
    both *verified* servers (the official remote server and domdomegg). A server that exposes the same
    record/schema tools under a **different word separator** — hyphenated (`list-records-for-table`)
    or camelCase (`listRecords`, `getRecord`) — will **not** match any stem, so the call falls through
    the non-base-scoped allow branch and reaches **any** base, allowlisted or not. This is a
    portability gap, not a hole against the two verified servers (both use underscores), but the
    42-tool `rashidazarang/airtable-mcp` server's individual tool names are unverified in the landscape
    note and could use another convention. Before trusting this policy against any unverified server,
    confirm the exact tool names with the dump-input debug technique; if they use hyphens or camelCase,
    add those spellings (e.g. `list-record`, `listrecord`) to `base_scoped_stems`.

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

```rego
package airtable.ingress.fence_base_allowlist

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

# ---------------------------------------------------------------------------
# Allowlist configuration — PLACEHOLDERS, replace at import time.
#
# Airtable sensitivity is a property of the base (`app…`), and a single OAuth
# grant / PAT with the `workspacesAndBases:read` scope spans the whole
# workspace. Pin the exact canonical base IDs the agent is sanctioned to touch.
# Base IDs are case-sensitive, so this set is compared verbatim (not lowered).
allowed_bases := {
    "appEXAMPLEBASE0001", # e.g. the governed CRM base
    "appEXAMPLEBASE0002", # e.g. the governed support-tracker base
}

# ---------------------------------------------------------------------------
# Tool matching. The gateway prefixes tool names with the configured MCP server
# name (separator not standardized), and the official server uses verbose
# `*_for_table` / `*_for_page` spellings while the community servers use terse
# names. Match case-insensitively by substring on canonical stems so both
# spellings and any prefix are covered. Verify exact names with the dump-input
# debug technique.
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))

# Canonical stems of the record/schema/page tools that carry a `baseId`.
# Singular stems (e.g. `list_record`) are substrings of their plural spellings
# (`list_records`, `list_records_for_table`, `list_records_for_page`), so one
# stem covers every variant.
base_scoped_stems := {
    "list_record", # list_records, list_records_for_table, list_records_for_page
    "display_record", # display_records_for_table (official interactive widget; reads records)
    "search_record", # search_records
    "get_record", # get_record, get_record_for_page
    "create_record", # create_record, create_records_for_table
    "update_record", # update_records, update_records_for_table
    "create_table", # official + community schema create
    "update_table",
    "create_field",
    "update_field",
}

is_base_scoped_tool if {
    some stem in base_scoped_stems
    contains(tool_name, stem)
}

# ---------------------------------------------------------------------------
# Argument extraction — object.get everywhere so a missing baseId fails closed.
args := object.get(object.get(input, "payload", {}), "args", {})

requested_base := object.get(args, "baseId", "")

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

# Any tool that is not base-scoped (ping, list_bases, search_bases,
# list_workspaces, and every other non-record tool) passes through untouched.
allow if {
    not is_base_scoped_tool
}

# Base-scoped tools are allowed only when they name an allowlisted base.
# A missing/empty baseId resolves to "" which is not in the set -> deny.
allow if {
    is_base_scoped_tool
    allowed_bases[requested_base]
}

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

# Base-scoped tool naming a base that is not on the allowlist.
reasons contains msg if {
    is_base_scoped_tool
    requested_base != ""
    not allowed_bases[requested_base]
    msg := sprintf("Airtable base %s is not on the sanctioned-base allowlist, so the agent may not operate on it. Request base onboarding through your data-governance owner, or contact them if you believe this base is already governed.", [requested_base])
}

# Base-scoped tool that supplied no baseId at all — fail closed.
reasons contains msg if {
    is_base_scoped_tool
    requested_base == ""
    msg := "This Airtable tool operates on a specific base but no baseId was supplied, so it cannot be matched against the sanctioned-base allowlist. Re-issue the call naming an allowlisted base, and request base onboarding through your data-governance owner if the base you need is not yet allowlisted."
}

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