--- name: Fence Writes to Sensitive Asana Projects tags: - asana - fence-sensitive-scopes - ingress - soc2 - gdpr-ccpa publishedAt: 2026-07-12 description: | # asana / fence-sensitive-projects **Direction:** ingress (`tool_pre_invoke`) **Default:** deny writes that target a fenced project GID for callers outside the mapped group, allow otherwise **Package:** `asana.ingress.fence_sensitive_projects` ## What it does Asana is routinely used for HR (hiring, performance, offboarding), legal, M&A, and incident work; those project bodies, comments, custom fields, and status updates carry PII and confidential material. Sensitivity is a property of the **project GID**, not the tool. This policy converts Asana's single-OAuth-token scope into per-team least privilege by pinning sensitive project GIDs to the IdP group required to write to them. At ingress it inspects the write tools that can push content into — or grant visibility on — a project, extracts every project/section GID that appears **directly** in the payload, and denies the call when a requested GID is fenced and the caller lacks the mapped IdP group. Inspected write tools: - **Official V2 batch** `create_tasks` / `update_tasks` — each carries an array of up to **50** task objects; the policy iterates every element and reads its `project`, `projects[]`, and `section` fields. - **Official** `add_comment` and `create_project_status_update` — comment / status-update writes that are externally visible to project followers. - **Community** (`roychri` / `cristip73`) `asana_create_task` / `asana_update_task` (`project_id`, `projects[]`), `asana_create_task_story` (the community comment tool), `asana_add_task_to_section` (`section_id`), `asana_add_followers_to_task` (grants a task's visibility to new followers), `asana_create_project_status` (the community twin of `create_project_status_update`, broadcasts to project followers), and `asana_add_project_to_task` (adds a task into a project by project GID — grants project membership, i.e. content + visibility, on the fenced project). The protected set carries **placeholder** GIDs mapped to placeholder groups (`hr`, `legal`, `ma`, `incident`). Pin your tenant's real project GIDs (and group names), or empty the list, at import time — a project reached by a GID not on the list is not fenced. Group membership is read from `input.subject.claims.groups` via `object.get(input.subject, "claims", {})` chains and fails closed: a missing, empty, or malformed `groups` claim never grants a write to a fenced project — no group means not permitted. Every tool this policy does not inspect passes through untouched. ## Why ingress and not egress These are writes with permanent, often externally-visible side effects: once the call reaches Asana the task exists, the comment or status update has notified followers (including external guests on shared projects), and follower additions have granted visibility. Egress can only mask the response, not undo the write or the notification. Ingress denial is the only point at which the write into a fenced project is actually prevented. ## Compliance alignment - **SOC 2 C1.1** — supports identification and protection of confidential information by gating agent writes to designated confidential projects to their mapped groups; **P4.1** — supports limiting personal-information use to identified purposes by keeping PI-bearing projects (HR / M&A) behind role fences on the agent channel. - **GDPR Art. 9** — supports special-category protection by fencing writes to projects holding health, HR, or other Art. 9 data; **Art. 5(1)(b)** — supports purpose limitation by keeping sensitive projects scoped to the team whose purpose they serve; **CPRA §1798.121** — supports the right to limit use of sensitive personal information by fencing SPI projects to a minimal group. (Per the coverage matrix, these rows map to policy family **PF-23** (`fence-sensitive-scopes`).) ## Tool name matching Tool matching is **suffix-based** and case-insensitive so it covers both the official V2 bare-verb spellings and the `asana_`-prefixed community spellings behind any gateway server-name prefix. A name matches a suffix when it equals it exactly, or ends with the suffix preceded by a `-` or `_` separator (e.g. `asana-create_tasks`, `asana_create_tasks`). The two divergent spellings are matched by distinct suffixes: - official plural batch `create_tasks` / `update_tasks` vs community singular `asana_create_task` / `asana_update_task`; - official `add_comment` vs community `asana_create_task_story` (same externally-visible action, two suffixes). The name is read from **both** the PARC field (`input.resource.name`) and the legacy alias (`input.payload.name`) via `object.get` chains, coerced to a lowercased, whitespace-trimmed string (a missing or non-string value resolves to `""` rather than leaving the match undefined), and the two fields are matched **independently** — a malformed value in one cannot suppress a real write suffix in the other. Asana's tool set evolves (Asana says to use `tools/list` for the current set; `add_comment` was absent at V2 launch and added later). Verify the exact names your gateway sends with the dump-input debug technique before relying on this in production. ## Argument shape Asana does **not** publish the V2 per-parameter JSON schema (it is only visible via a live `tools/list`), so the GID-bearing argument keys below are **documented field lists, not verified schema keys**. The policy scans a fixed set of candidate keys both at the top level and inside each batch element: - scalar GID keys: `project`, `section`, `project_id`, `section_id`, `parent`; - array GID key: `projects[]`. `parent` is scanned because Asana's status-update surface (`create_project_status_update` / `asana_create_project_status`) carries the target project GID under `parent` (the native `POST /status_updates` parameter), not `project`; scanning `parent` closes the broadcast-into-a-fenced-project path. `parent` is also the subtask-parent key on `create_tasks` / `update_tasks`, where it holds a **task** GID — harmless to scan, because a task GID never equals a fenced **project** GID, so no false-positive deny is introduced (a subtask created under a task that itself lives in a fenced project is the transitive case below, still a documented pass-through). Batch arrays for `create_tasks` / `update_tasks` are read under candidate keys `tasks`, `data`, `items` (the array-holding key is **unverified** — same caveat as the batch policy). GIDs are normalized to a trimmed string, so numeric and string encodings both match. A candidate key that is missing or non-scalar is simply skipped. ## Examples ### Allowed — write to a non-fenced project ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-create_tasks", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "asana-create_tasks", "args": { "tasks": [ { "name": "ship it", "project": "1209999999999" } ] } // not fenced } } } ``` `allow = true`, no reason. ### Allowed — write to a fenced project by a member of the mapped group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-add_comment", "type": "tool" }, "subject": { "sub": "google-apps|hr@example.com", "claims": { "groups": ["hr"] } }, "payload": { "name": "asana-add_comment", "args": { "project": "1201111111111", "text": "note" } } } } ``` `allow = true`. ### Denied — write to a fenced project by a caller outside the group ```jsonc { "input": { "action": "tool_pre_invoke", "resource": { "name": "asana-create_tasks", "type": "tool" }, "subject": { "sub": "google-apps|dev@example.com", "claims": { "groups": ["engineering"] } }, "payload": { "name": "asana-create_tasks", "args": { "tasks": [ { "name": "leak", "project": "1201111111111" } ] } // fenced -> hr } } } ``` `allow = false`, `reason = "Asana project 1201111111111 is fenced as sensitive and requires the 'hr' IdP group to write to it; (...)"`. ## Composition This policy only fences writes when a protected GID appears **directly** in the payload. It is deliberately paired with: - **`apps/asana/freeze-destructive-ops`** — freezes `delete_task` and community delete tools; a fenced project's tasks can still be *deleted* without this companion. - **`apps/asana/cap-batch-mutation`** — caps batch blast radius; the fence bounds *which* projects a batch may touch, the cap bounds *how many* records per call. - An **egress PII redaction** policy on `get_task` / `search_tasks` / `asana_get_task_stories` responses, to cover reads (this policy is write-side only) and mop up regulated values. ## Known limitations - **Direct-GID matching only (the core limitation).** The rule fires only when a protected project/section GID appears directly in the payload. It **cannot** transitively resolve a task's project from a bare `parent` / `task_id` reference (e.g. `add_comment`, `asana_create_task_story`, `asana_add_followers_to_task`, or an `update_tasks` element that names only a task GID). Such a call passes through this policy. This fence must therefore be **paired** with the destructive-ops and batch companions above, not relied on alone. - **Placeholder configuration.** The GIDs and group names (`hr`, `legal`, `ma`, `incident`) are placeholders — replace them with your deployment's real Asana project GIDs and IdP group names, or empty the list, at import time. **Group names are placeholders — replace `hr` with your IdP's group name at import time.** Confirm your IdP actually emits a `groups` claim (Auth0 and most IdPs require explicit configuration); with no `groups` claim the policy fails closed (writes to fenced projects are denied for everyone). - **`groups` must be an array of strings.** A string-valued or otherwise malformed `groups` claim fails closed (fenced writes deny). If your IdP emits groups under a different claim name, update `caller_has_group` in the Rego. - **Unverified argument keys.** Asana does not publish the V2 per-parameter schema; the GID field keys (`project`, `section`, `project_id`, `section_id`, `parent`, `projects[]`) and the batch array keys (`tasks`, `data`, `items`) are best-effort candidate lists. `parent` was added after a red-team review found that a status-update broadcast (`create_project_status_update` / `asana_create_project_status`) names its project GID under `parent`, not `project`, and so slipped the fence uninspected. Confirm the real keys via a live `tools/list` and extend the constants. A write that names its project GID under a key not in the list is not fenced (same class as the direct-GID limitation). Two shapes are known **not** covered and pass through: a GID wrapped in an object (`projects: [{"gid": "..."}]`) rather than a bare GID string, and a payload whose `args` is a positional array rather than a named-argument object — neither is a shape the MCP tool surface is expected to emit, but both are residuals if a nonconforming server does. - **Structural community writes into a project are not fenced.** The community `asana_create_section` / `asana_create_section_for_project` tools create a section *inside* a project by direct project GID. They are deliberately **not** in `write_suffixes`: they add structure, not PII-bearing task bodies/comments/status broadcasts, so they are lower leak value and left as a documented residual (a pinned allow test locks this pass-through). If your threat model treats section creation inside a fenced project as sensitive, add those suffixes to `write_suffixes` — the existing `project` / `project_id` scalar keys already cover their GID argument. `asana_update_project` (which modifies a fenced project's own settings, including `privacy_setting`) is likewise out of scope here; pair the PF `privacy-flip` companion for that surface. - **Tool-inventory drift.** Asana's V2 set evolves and community forks add tools; a new write tool with a different suffix is not inspected until added to `write_suffixes`. For a hard guarantee against unknown tools, compose the PF-28 `default-deny-unknown-tools` allowlist alongside this policy. - **Suffix matching is portable but broad.** A hypothetical unrelated tool whose name ends in one of the matched suffixes preceded by `-`/`_` would also be inspected (and allowed unless it names a fenced GID). For a scope fence, over-inspection is the safe direction. > **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: - asana industries: [] bundles: - soc2 - gdpr-ccpa experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package asana.ingress.fence_sensitive_projects # Deny-by-default. The pass-through allow branch (`not is_write_tool`) neutralizes # this default for every tool this policy does not inspect, so the default-deny # bites only on the matched write tools that target a fenced project GID. default allow := false # --------------------------------------------------------------------------- # Fence configuration — PLACEHOLDERS, replace (or empty) at import time. # # Asana project sensitivity is a property of the project GID, not the tool. # HR / legal / M&A / incident projects hold PII and confidential material. # Each entry pins a sensitive project (or section) GID to the IdP group allowed # to write to it. The list ships with placeholder GIDs mapped to placeholder # groups; pin your tenant's real GIDs (and group names), or empty the list, at # import time. A project whose GID is not on this list is NOT fenced. Group # names are compared case-insensitively against the caller's `groups` claim. protected_projects := [ {"gid": "1201111111111", "group": "hr"}, # e.g. HR / recruiting project {"gid": "1202222222222", "group": "legal"}, # e.g. Legal / contracts project {"gid": "1203333333333", "group": "ma"}, # e.g. M&A / corp-dev project {"gid": "1204444444444", "group": "incident"}, # e.g. Security-incident project ] # --------------------------------------------------------------------------- # Identity — groups are read via object.get chains so a missing subject / claims # / groups fails closed (no group -> no write to a fenced project). The is_array # guard is load-bearing: a non-array `groups` (string, object, number) must fail # closed rather than let `some g in groups` iterate an unexpected shape. caller_has_group(group) if { claims := object.get(input.subject, "claims", {}) groups := object.get(claims, "groups", []) is_array(groups) some g in groups is_string(g) lower(g) == lower(group) } # --------------------------------------------------------------------------- # Tool matching (suffix-based, case-insensitive, for portability across gateway # server-name prefixes). Read the name from BOTH the PARC field and the legacy # alias; a missing OR non-string value resolves to "" (never leaves the match # undefined — a fail-OPEN bypass). trim_space strips padding so a trailing space # or newline cannot slip past the suffix check. The two fields are matched # independently so a malformed value in one cannot suppress a real suffix in the # other. name_of(key) := trim_space(lower(v)) if { v := object.get(object.get(input, key, {}), "name", "") is_string(v) } name_of(key) := "" if { v := object.get(object.get(input, key, {}), "name", "") not is_string(v) } resource_name := name_of("resource") payload_name := name_of("payload") # A name matches a suffix when it equals it exactly, or ends with the suffix # preceded by a `-` or `_` separator (tolerates any gateway server-name prefix). matches_name(name, suffix) if { name == suffix } matches_name(name, suffix) if { endswith(name, sprintf("-%s", [suffix])) } matches_name(name, suffix) if { endswith(name, sprintf("_%s", [suffix])) } tool_matches(suffix) if { matches_name(resource_name, suffix) } tool_matches(suffix) if { matches_name(payload_name, suffix) } # Write tools that can push content into — or grant visibility on — a project. # Official plural batch verbs and community `asana_`-prefixed singular verbs are # matched by distinct suffixes. Comment writes appear under two suffixes # (`add_comment` official, `asana_create_task_story` community). write_suffixes := [ "create_tasks", # official V2 batch create (up to 50 objects) "update_tasks", # official V2 batch update (up to 50 objects) "add_comment", # official comment "create_project_status_update", # official status update "asana_create_task", # community singular create "asana_update_task", # community singular update "asana_create_task_story", # community comment "asana_add_task_to_section", # community add-to-section (section_id) "asana_add_followers_to_task", # community follower add (grants visibility) "asana_create_project_status", # community status update (twin of create_project_status_update) "asana_add_project_to_task", # community add-task-to-project (grants project membership by project GID) ] is_write_tool if { some s in write_suffixes tool_matches(s) } # --------------------------------------------------------------------------- # GID extraction — object.get everywhere; GIDs may arrive as numbers or strings, # so normalize both to a trimmed string. Missing / non-scalar values are skipped. args := object.get(object.get(input, "payload", {}), "args", {}) # Scalar GID-bearing argument keys, and the candidate batch-array keys for the # official create_tasks / update_tasks tools (array key is UNVERIFIED — see the # batch policy). These are documented field lists, not verified schema keys. scalar_gid_keys := ["project", "section", "project_id", "section_id", "parent"] batch_keys := ["tasks", "data", "items"] to_gid(x) := trim_space(x) if is_string(x) to_gid(x) := sprintf("%v", [x]) if is_number(x) # Top-level scalar GID fields (community singular tools, add_comment, # create_project_status_update, asana_add_task_to_section, and any create/update # call that carries the field flat). requested_gids contains id if { is_write_tool some key in scalar_gid_keys id := to_gid(object.get(args, key, null)) id != "" } # Top-level projects[] array. requested_gids contains id if { is_write_tool some raw in object.get(args, "projects", []) id := to_gid(raw) id != "" } # Batch-array elements (official create_tasks / update_tasks, up to 50 objects): # each element's scalar GID fields. requested_gids contains id if { is_write_tool some bkey in batch_keys arr := object.get(args, bkey, null) is_array(arr) some el in arr is_object(el) some key in scalar_gid_keys id := to_gid(object.get(el, key, null)) id != "" } # Batch-array elements' projects[] arrays. requested_gids contains id if { is_write_tool some bkey in batch_keys arr := object.get(args, bkey, null) is_array(arr) some el in arr is_object(el) some raw in object.get(el, "projects", []) id := to_gid(raw) id != "" } # --------------------------------------------------------------------------- # Fence check — a requested GID is fenced and the caller lacks the mapped group. blocked contains entry if { is_write_tool some entry in protected_projects requested_gids[entry.gid] not caller_has_group(entry.group) } # --------------------------------------------------------------------------- # Allow rules. # Any tool this policy does not inspect passes through untouched. allow if { not is_write_tool } # An inspected write passes when no fenced GID is targeted away from the caller # (includes writes to non-fenced projects and writes that name no GID at all). allow if { is_write_tool count(blocked) == 0 } # --------------------------------------------------------------------------- # Deny reasons — one per fenced GID the caller may not write to. reasons contains msg if { some entry in blocked msg := sprintf("Asana project %s is fenced as sensitive and requires the '%s' IdP group to write to it; this call targets it directly. Route the write through a member of the '%s' group, or contact InfoSec if this fence looks wrong.", [entry.gid, entry.group, entry.group]) } reason := joined if { count(reasons) > 0 reason_list := sort([r | some r in reasons]) joined := concat("; ", reason_list) } ```