dtwo Policy Store

Confine Airtable Agent to Allowlisted Bases

An Airtable OAuth grant (or Personal Access Token) with the workspacesAndBases:read scope spans the entire workspace — every base the connected identity can…

Direction
ingress
Rego package
airtable.ingress.fence_base_allowlist
App
airtable
Bundles
soc2gdpr-ccpa
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:438d1a0b4a73d17af63a9d381d7d6eee643181e6ec9401461475e8f17fec9ed1

airtablefence-sensitive-scopesingresssoc2gdpr-ccpa

What this policy does

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 readslist_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 writescreate_record*, update_records*.
  • Schema writescreate_table, update_table, create_field, update_field.

Discovery tools that carry no baseIdping, 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; 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.

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

{
  "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

{
  "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

{
  "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.

Policy source (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)
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)