Fence Writes to Sensitive Asana Projects
Asana is routinely used for HR (hiring, performance, offboarding), legal, M&A, and incident work; those project bodies, comments, custom fields, and status…
- Direction
- ingress
- Rego package
asana.ingress.fence_sensitive_projects- App
- asana
- Bundles
- soc2gdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:90fa434b8f6290758ceee9e9486db7d31c48e7abdf43a9b7514c0457f688fe85
asanafence-sensitive-scopesingresssoc2gdpr-ccpa
What this policy does
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 itsproject,projects[], andsectionfields. - Official
add_commentandcreate_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 ofcreate_project_status_update, broadcasts to project followers), andasana_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_tasksvs community singularasana_create_task/asana_update_task; - official
add_commentvs communityasana_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
{
"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
{
"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
{
"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— freezesdelete_taskand 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_storiesresponses, 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_idreference (e.g.add_comment,asana_create_task_story,asana_add_followers_to_task, or anupdate_taskselement 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 — replacehrwith your IdP's group name at import time. Confirm your IdP actually emits agroupsclaim (Auth0 and most IdPs require explicit configuration); with nogroupsclaim the policy fails closed (writes to fenced projects are denied for everyone). groupsmust be an array of strings. A string-valued or otherwise malformedgroupsclaim fails closed (fenced writes deny). If your IdP emits groups under a different claim name, updatecaller_has_groupin 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.parentwas added after a red-team review found that a status-update broadcast (create_project_status_update/asana_create_project_status) names its project GID underparent, notproject, and so slipped the fence uninspected. Confirm the real keys via a livetools/listand 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 whoseargsis 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_projecttools create a section inside a project by direct project GID. They are deliberately not inwrite_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 towrite_suffixes— the existingproject/project_idscalar keys already cover their GID argument.asana_update_project(which modifies a fenced project's own settings, includingprivacy_setting) is likewise out of scope here; pair the PFprivacy-flipcompanion 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-28default-deny-unknown-toolsallowlist 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.
Policy source (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)
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)
Related policies
Airtable: Redact PII in Record Reads
Scans the responses of the Airtable record-read tools — the calls that return row fields values — and rewrites high-confidence PII shapes to a fixed…
Asana: Redact PII in Task & Comment Reads
On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — notes,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
Block BigQuery Exfiltration and Cross-Project Writes
Inspects the raw GoogleSQL string carried by BigQuery SQL tools and denies any statement that moves data out of the tenant's own project — even when the call…
bigqueryguard-warehouse-exportingresssqlexfiltrationsoc2pci-dssgdpr-ccpa
Block Bulk Export & External Staging (Snowflake)
Blocks Snowflake SQL-execution tool calls whose query text moves whole tables off the Snowflake perimeter — bulk export to cloud storage or a stage, and…
snowflakeguard-warehouse-sqlexportexfiltrationingresssoc2pci-dssgdpr-ccpa