{"id":8301,"plugin_id":"plugin_asdk_app_6a183f5bead08191b494b99bc881e8c0","kind":"skill","collection_source":null,"comparison_source":null,"observed_at":"2026-09-30T22:52:28.442Z","digest":"c727c95c6fb19dc40cd8c95b77992c00af03079ad1c250b7a4648daeae486317","against":null,"payload":{"name":"descope-fga-schema","description":"Author, edit, or apply a Descope FGA schema using the ReBAC/ABAC DSL. Use this skill whenever the user asks to create a new FGA schema, modify an existing one, add types/relations/permissions/conditions, review an authorization model, or apply schema changes to a Descope project. Trigger even if the user says things like \"set up authorization\", \"define roles and permissions\", \"add team-based access\", \"make this endpoint check FGA\", or \"update my authz model\" — these almost always mean an FGA schema change.","included_files":[],"skill_md_contents":"---\nname: descope-fga-schema\ndescription: Author, edit, or apply a Descope FGA schema using the ReBAC/ABAC DSL. Use this skill whenever the user asks to create a new FGA schema, modify an existing one, add types/relations/permissions/conditions, review an authorization model, or apply schema changes to a Descope project. Trigger even if the user says things like \"set up authorization\", \"define roles and permissions\", \"add team-based access\", \"make this endpoint check FGA\", or \"update my authz model\" — these almost always mean an FGA schema change.\n---\n\n# FGA DSL Authoring\n\nHelp the user design and apply Descope FGA schemas. The workflow is: understand the requirement → draft the DSL → validate via dry run → show the user + any data loss warnings → get confirmation → apply.\n\n## MCP Setup — check first, stop if missing\n\n**Before doing anything else**, check whether the Descope Management MCP is connected by looking for tools whose names contain `FGASchema` or `DryRunSchema` (e.g. `mcp__descope__DryRunSchema`). The exact prefix depends on how the user installed the MCP, but the operation IDs are `DryRunSchema`, `CreateFGASchema`, and `GetFGASchema`.\n\n**If the tools are not found:** output only the message below, then end your turn. Do not generate a schema, do not say \"here's what I'll apply once connected\", do not do any design work, do not continue:\n\n> The Descope Management MCP is required. If not yet installed, install and authorize it, then restart Claude Code and re-run `/descope-fga-schema`.\n> If already installed, it may need authorization. Authorize the Descope MCP, then restart Claude Code and re-run `/descope-fga-schema`.\n\n**If the tools are found:** call `GetFGASchema` immediately as a connectivity probe before doing any other work. If this call returns an authorization error, output only the message below and end your turn:\n\n> The Descope MCP is installed but not authorized. Authorize it, restart Claude Code, and re-run `/descope-fga-schema`.\n\nAll FGA operations go through MCP tool calls — never make raw HTTP requests yourself.\n\nOnce connected, use the `GetFGASchema` tool to read the current schema before editing — always do this when the user asks to modify an existing schema.\n\n## Grammar\n\nEvery schema begins with exactly:\n```\nmodel AuthZ 1.0\n```\nNo other name or version is accepted by the API.\n\nFull structure:\n```\nmodel AuthZ 1.0\n\n[constraint <Name>[:<Kind>][(args...)]]*\n[condition <Name>(<param type, ...>) { <CEL bool expr> }]*\n\ntype <TypeName>\n  [relation <name>: <TypeRef> [| <TypeRef>]* [with <condExpr>]]*\n  [permission <name>: <expr> [with <condExpr>]]*\n```\n\nKeywords: `model` `type` `relation` `permission` `condition` `constraint` `with`\n\nOperators:\n- Permission expr: `|` union, `&` intersect, `-` subtract. Mix operators with parens: `a | (b - c)`\n- Set arrow: `relation.permission` — walks a stored relation to reach the subject's own permissions (e.g. `parent.can_view`)\n- Target set: `Type#relation` — see dedicated section below\n- `with` clause (relations and permissions): `&` AND, `|` OR, `!` NOT, parens: `with A & (B | !C)`. Conditions are evaluated at **check time** — `with` gates whether the relation or permission counts during evaluation. Only one `with` clause is allowed per relation or permission definition — combine multiple conditions inside it with `&`/`|`/`!`.\n\n**No comments** — the DSL parser has no comment token.\n\nNaming: **PascalCase** for Types, Conditions, Constraints. **snake_case** for relations and permissions.\n\n## Target Set Pattern (`Type#relation`)\n\nWhen a relation should be held by members of a group (e.g. \"any member of this Team\"), put `Type#relation` directly in the relation definition. This stores individual member subjects — the right granularity for permission checks.\n\nThe indirect way — storing the group itself and deriving membership via a permission — produces correct relation expansion, but it introduces a `contributor_team` relation with no semantic meaning of its own. The only meaningful entity is the individual member. The target set syntax is more concise and directly expresses the intent.\n\n**Avoid (extra relation with no semantic value):**\n```\ntype Repository\n  relation contributor_team: Team\n  permission contributor: contributor_team.member\n```\n\n**Prefer (concise, direct):**\n```\ntype Repository\n  relation contributor: Team#member\n```\n\nYou can mix direct subjects with target set subjects: `relation editor: User | Team#member`\n\n## ABAC Anti-Patterns to Avoid\n\n### Never use a \"blocked\" relation + subtraction to express a condition\n\n`with` conditions are evaluated at **check time** — when a permission check is made against the context passed in the request. Relations are always stored unconditionally; the condition only affects whether the relation counts during permission evaluation.\n\nThe `blocked` relation + subtraction pattern is wrong because it requires manually maintaining a separate set of `blocked` edges in the DB for every excluded user. It's the wrong tool: use `with !Condition` on the relation that grants access instead — it is evaluated automatically at check time with no extra stored relations.\n\n```\n// NEVER do this — requires maintaining a separate \"blocked\" edge per user in the DB\nrelation creator: User\nrelation blocked: User with NorthKorea\npermission can_delete: creator - blocked\n\n// Right — condition evaluated automatically at check time; no extra edges\nrelation creator: User with !NorthKorea\npermission can_delete: creator\n```\n\n### Don't write custom CEL when a built-in constraint covers it\n\nA custom `condition` that checks a numeric range is just reinventing `NumRange` (or `NumAtLeast`/`NumAtMost`). Built-in constraints are more concise, less error-prone, and form a common vocabulary that makes schemas easier for both humans and agents to read and reason about. Use them.\n\n**Wrong:**\n```\ncondition DuringBusinessHours(seconds_since_midnight int) { seconds_since_midnight >= 32400 && seconds_since_midnight < 61200 }\n```\n\n**Right:**\n```\nconstraint BusinessHours:NumRange(32400, 61200)\n```\n\n(Use a named alias when you want a descriptive name for the constraint.)\n\n## Relations vs Permissions\n\nA **relation** adds an edge to the pure relations graph. A **permission** is a derived rule that reuses existing relations — it adds edges only in the ReBAC graph without introducing new pure-graph edges. Fewer pure-graph edges means less to iterate during checks and a higher chance of cache hits across all checks in the schema, so permissions are more concise and keeping the pure graph lean tends to improve overall check performance as the system scales. Prefer satisfying a requirement with a permission whenever possible. Only introduce a new relation when a direct stored link is truly needed.\n\nWhen a permission is a strict superset of another, express it by referencing the narrower permission rather than repeating its expansion. This keeps schemas concise and makes the access hierarchy self-documenting — a reader immediately sees that `can_admin` implies `can_write`, which implies `can_read`.\n\n**Avoid (repeats relations across permissions):**\n```\npermission can_admin: owner\npermission can_write: owner | editor\npermission can_read: owner | editor | viewer\n```\n\n**Prefer (each permission builds on the previous):**\n```\npermission can_admin: owner\npermission can_write: can_admin | editor\npermission can_read: can_write | viewer\n```\n\n## Built-in Constraints\n\nUse built-in constraints before reaching for custom CEL.\n\n| Constraint | Runtime params (zero-arg form) | Hardcoded form |\n|---|---|---|\n| `IpRange` | `ip ipaddress, ip_range string` | `IpRange(\"10.0.0.0/8\")` |\n| `IpList` | `ip ipaddress, allowed_ips list` | `IpList(\"1.2.3.4\",\"5.6.7.8\")` |\n| `DateExpiryEpochSeconds` | `now_epoch_seconds int, expiry_epoch_seconds int` | `DateExpiryEpochSeconds(1735689600)` |\n| `StringMatchRegex` | `str string` | `StringMatchRegex(\"^admin_.*\")` (regex required) |\n| `NumAtLeast` | `num double, min int` | `NumAtLeast(18)` |\n| `NumAtMost` | `num double, max int` | `NumAtMost(100)` |\n| `NumRange` | `num double, min int, max int` | `NumRange(0,100)` (min ≤ max) |\n| `BoolCheck` | `bool bool, expected bool` | `BoolCheck(true)` |\n| `GeoCountry` | `country_code string, allowed_countries list` | `GeoCountry(\"US\",\"GB\")` (ISO 3166-1 alpha-2) |\n| `IntList` | `int int, allowed_ints list` | `IntList(1,2,3)` |\n| `LabelList` | `label string, allowed_labels list` | `LabelList(\"foo\",\"bar\")` |\n\n**Multiple constraints of the same kind:** You cannot declare the same constraint kind more than once without a named alias — the alias is required to distinguish them. Named aliases share the same runtime param names as the original kind (the alias only changes the constraint's identifier, not its params). This is fine when both constraints operate on the same parameter. If you need two constraints that operate on genuinely different parameters, use a custom CEL condition with a unique param name instead:\n```\n// Two GeoCountry constraints sharing the same country_code param — alias required, shared param is intentional\nconstraint FiveEyes:GeoCountry(\"US\",\"GB\",\"CA\",\"AU\",\"NZ\")\nconstraint Sanction:GeoCountry(\"KP\",\"IR\",\"SY\",\"RU\")\n\n// Need a second IP check with a different param name? Use a custom condition\ncondition OfficeNetwork(office_ip ipaddress, office_range string) { office_ip.in_cidr(office_range) }\n```\n\n**Custom CEL** — only when no built-in covers the logic, or when alias-based param separation isn't enough:\n```\ncondition InNetwork(user_ip ipaddress, allowed_range string) { user_ip.in_cidr(allowed_range) }\n```\nCEL param types: `int`, `string`, `bool`, `double`, `list`, `ipaddress`. Body must return `bool`. Avoid nested `exists` — the evaluator enforces a cost limit.\n\n## Edit-Safety Protocol\n\nWhen editing an existing schema, first read the current schema with `GetFGASchema` so you have the real state.\n\n- If the user asks to add something already present, tell them exactly what exists and stop — don't silently overwrite.\n- Removing an entire **type** or a **relation definition** from the schema will cause all relation tuples of that type or relation to be permanently deleted from the database. **Editing the target type(s) of a relation definition is equivalent to deleting it and recreating it** — the same data loss risk applies. Always confirm with the user and make sure they understand the impact before proceeding.\n- **Exception: editing only the `with` condition of a relation does NOT delete tuples.** Relations are stored unconditionally; the condition is evaluated at check time. Changing `with CondA` to `with CondB` on an otherwise unchanged relation preserves all existing tuples — they simply start being evaluated against the new condition. This is safer than a full relation edit, but still requires caution: callers relying on the old condition's behavior will get different access results after the change.\n- Removing or editing a **permission** deletes no relation tuples, but any downstream permissions or checks that depended on it will silently stop working. Confirm with user.\n- Adding a new type, relation, or permission is generally safe.\n\n## Validation and Apply Workflow\n\nFollow this sequence every time you generate or edit a DSL:\n\n### Step 1 — Dry run\n\nUse the `DryRunSchema` MCP tool with the proposed DSL. This validates the schema and reports what data would be deleted if applied.\n\n- On error: the schema is invalid. Read the error message, fix the DSL, retry. Cap at 5 iterations — if still failing, stop and show the user the last error.\n- On success: continue to Step 2.\n\nThe response contains:\n```json\n{\n  \"deletesPreview\": {\n    \"hasDeletes\": true,\n    \"relations\": [\"folder#viewer\", \"doc#editor\"],\n    \"types\": [\"LegacyRole\"]\n  }\n}\n```\n\n### Step 2 — Show the user\n\nPresent:\n1. The full proposed DSL (formatted in a code block)\n2. If `hasDeletes` is true — a clear warning listing every relation type and namespace type that will be **permanently deleted** from the database\n\nExample warning:\n> **Warning: applying this schema will permanently delete all stored relations of these types:**\n> - `folder#viewer`\n> - `doc#editor`\n>\n> This cannot be undone. Confirm to proceed.\n\nIf `hasDeletes` is false, just show the schema and ask for confirmation.\n\n### Step 3 — Get confirmation\n\nEnd your turn after Step 2. Do not call `CreateFGASchema` in the same turn as `DryRunSchema` — the user must see the schema and any deletion warnings before you proceed. Wait for the user to reply with explicit approval (\"yes\", \"apply\", \"go ahead\", etc.).\n\n### Step 4 — Apply\n\nBefore calling `CreateFGASchema`, verify all three of the following are true:\n- You showed the full DSL in a code block in a prior turn (not in this turn)\n- You surfaced all deletion warnings from the dry-run response (or confirmed `hasDeletes` was false)\n- The user's most recent message is an explicit approval in response to your confirmation prompt\n\nIf any of these are not true, do not call `CreateFGASchema`. Go back to Step 2 instead.\n\nWhen all three are confirmed, call `CreateFGASchema` with the same DSL from the dry run. Confirm success to the user.\n\nThe reason this gate matters: `CreateFGASchema` is irreversible. Relation tuples deleted by a schema change cannot be recovered. Skipping confirmation is never safe, even when the change looks minor.\n\n## Examples\n\n### Basic ReBAC with hierarchy\n\n```\nmodel AuthZ 1.0\n\ntype User\n\ntype Folder\n\ntype Doc\n  relation owner: User\n  relation parent: Folder\n  permission can_view: owner | parent.owner\n  permission can_edit: owner\n```\n\n### Group membership via target set\n\n```\nmodel AuthZ 1.0\n\ntype User\n\ntype Team\n  relation member: User\n\ntype Repository\n  relation owner: User\n  relation contributor: User | Team#member\n  permission can_push: owner | contributor\n  permission can_read: can_push\n```\n\n### ABAC: time-gated access\n\n```\nmodel AuthZ 1.0\n\nconstraint ShiftHours:NumRange\n\ntype User\n\ntype PatientRecord\n  relation viewer: User with ShiftHours\n  relation owner: User\n  permission can_view: viewer | owner\n```\n\n### Reused constraint kind with aliases — and `with` on a permission\n\n```\nmodel AuthZ 1.0\n\nconstraint FiveEyes:GeoCountry(\"US\",\"GB\",\"CA\",\"AU\",\"NZ\")\nconstraint Sanction:GeoCountry(\"KP\",\"IR\",\"SY\",\"RU\")\nconstraint OfficeOnly:IpRange(\"10.0.0.0/8\")\n\ntype User\n\ntype Resource\n  relation allowed: User with FiveEyes & !Sanction\n  relation owner: User\n  permission can_access: allowed\n  permission can_delete: owner with OfficeOnly\n```\n\n`allowed` carries geo-gating on the relation — it applies to every permission that uses `allowed`. `can_delete` uses `with` on the permission itself so the IP restriction scopes only deletion, not access.\n\n### Nested permissions with `with` — conditions stack\n\n```\nmodel AuthZ 1.0\n\nconstraint BusinessHours:NumRange(32400, 61200)\nconstraint OfficeNetwork:IpRange(\"10.0.0.0/8\")\n\ntype User\n\ntype Document\n  relation reader: User\n  permission can_read: reader with BusinessHours\n  permission can_edit: can_read with OfficeNetwork\n```\n\n`can_edit` requires both `BusinessHours` (from `can_read`) **and** `OfficeNetwork` (from `can_edit`'s own `with`). Both conditions must be true at check time — `with` clauses on nested permissions accumulate.\n"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}