Read @fgadoc annotations from
charts/lfx-platform/files/model.fga and produce a
human-readable PERMISSIONS.md at the repo root.
Gotchas
[user:*]means "every user including anonymous". It produces an Everyone column in the table — see Step 2.- Throughout this document,
[user]means the type restriction list containsuseras one of its entries — not necessarily the only entry. For example,[user, team#member]satisfies[user]. The distinct restriction[user:*](wildcard) does not satisfy[user]. - Non-
@fgadoccomments may appear between an@fgadocannotation block and thetype/defineline it annotates (e.g. descriptive prose already in the file). Collect all consecutive# @fgadoc:*lines before atypeordefineline as that entity's annotation block, skipping any intervening non-@fgadoccomment lines. - The
#### Permission Inheritancesection lists only cross-type sources — i.e.<rel> from <field>references where<field>resolves to a different type. Same-typeor <peer>inclusions are intentionally omitted because they are already implicit in the ✅ / 🟡 columns of the table. - The intro block before
## Object typesmust be preserved exactly ifPERMISSIONS.mdalready exists and has content there. - The
<!-- generated-intro -->comment block at the top must be preserved exactly ifPERMISSIONS.mdalready exists.
Parsing @fgadoc annotations
Annotations are YAML-style comments placed immediately before the entity they describe:
# @fgadoc:alias Display Name — human-readable name for a type or relation
# @fgadoc:hide — suppress type (whole section) or relation (column)
# @fgadoc:jtbd Statement — one JTBD; multiple lines allowed per relation
Step 1 — Parse model.fga
Read charts/lfx-platform/files/model.fga and parse it as plain text.
For each type <name> block, extract:
| Field | How to find it |
|---|---|
| Raw type name | type <name> |
| Display name | @fgadoc:alias in preceding annotation block; else raw name in Title Case with underscores replaced by spaces |
| Hidden? | @fgadoc:hide in preceding annotation block |
For each define <relation>: line, extract:
| Field | How to find it |
|---|---|
| Raw relation name | define <relation>: |
| Display name | @fgadoc:alias in preceding annotation block; else raw name in Title Case with underscores replaced by spaces |
| Hidden? | @fgadoc:hide in preceding annotation block |
| JTBD list | All @fgadoc:jtbd lines in preceding annotation block |
| Define expression | Everything after : on the define line |
| Direct user grant? | Has [user] (see Gotchas for what this means) |
| Public wildcard? | [user:*] appears as one of the type restrictions in [...] |
| Indirect? | Does not have [user] — the relation cannot be granted directly to a user on this object |
| Column-worthy? | A relation that is indirect AND has at least one non-peer source in its own define expression: a <rel> from <field> term where <field> resolves to a different type, or any type restriction in [...] other than user/user:*. Relations that are purely unions of same-type peers (e.g. writer or meeting_coordinator) are already represented by their constituent columns and are not shown separately. |
Step 2 — Build the JTBD × relation matrix
2a — Determine visible columns
For each visible type (not hidden), determine two sets of visible columns:
Direct-grant columns — relations that have [user] and are not hidden.
Indirect-only columns — relations that are indirect (no [user]) and
column-worthy (have at least one non-peer source — a cross-type
<rel> from <field> term or any type restriction other than
user/user:*) and are not hidden and would have at least one ✅
cell (i.e. their reachable JTBD pool is non-empty — see Step 2c for how to
compute this). These columns represent permissions that cannot be granted
directly to a user on this object. Their header text is italicized in
the Markdown table (wrap the display name in *...*).
Additionally, if any relation in the type has [user:*] in its define
expression (even a hidden relation), include an Everyone column as the
rightmost column. The Everyone column header is always italicized (*Everyone*).
It is special: it uses the 🟡 marker instead of ✅, and it collects JTBDs
from all relations that contain [user:*].
2b — Determine JTBD rows
Include ALL @fgadoc:jtbd statements across ALL relations of the type,
deduplicated. Do not filter out JTBDs whose relation has no visible
column — they still appear as rows (they may have a 🟡 in the Everyone
column).
Row ordering: Sort JTBD rows using the following priority rules, applied in order:
Base object first. JTBDs that describe viewing, reading, or accessing the object itself (the type; may be phrased as "details", "definition", or just the type name) come first. If viewing the base object is bundled with other operations in a single JTBD (e.g. "View a meeting & its attachments"), it still sorts first — especially when it is the JTBD that carries the 🟡 Everyone marker.
Settings next. JTBDs that refer to "settings" of the object come immediately after the base-object group.
Attributes in Read → Update → Delete order. For each logical group of related attributes or sub-resources (e.g. members, invites, links), sort Read operations before Update/Create before Delete. Group related attributes together so that Read/Update/Delete for the same thing are adjacent.
Child resource creation last. JTBDs that create child resources of another type come last. If creating a child of the same type is allowed, list that first among the child-creation group. Otherwise order child-creation JTBDs by the order their target types appear in model.fga.
2c — Compute cell values
OpenFGA semantics primer: When relation B's define says or A (B
includes A), it means anyone who has relation A also satisfies relation B.
In other words, A ⊆ B — A is the more privileged permission. A writer who is
included in auditor (auditor: ... or writer) automatically has auditor
access too, because writers are a subset of auditors.
Consequence for columns: A column represents a permission. For direct-grant columns, a user is directly granted the permission. For indirect-only columns, a user reaches the permission via a grant on a foreign object. In both cases, the column should show ✅ for every action that permission allows — including actions inherited upward from any relation that includes this permission.
For each (JTBD, column) pair:
For a direct-grant column (has [user]) or an indirect-only column:
Build the upward reachability set for the column's relation: starting
from that relation, find every other relation in the same type whose define
expression contains or <this-relation> (directly or transitively). Collect
the JTBD lists from the starting relation itself and every relation in
the upward reachability set. If any of those JTBD lists contains the target
JTBD, mark ✅.
Do not traverse downward (i.e., do not add JTBDs from relations listed
via or <peer> inside this column's own define — those are permissions this
permission subsumes, not permissions that subsume this one).
Self-referential conditional fields (🟡):
A relation's define may contain <rel> from <field> terms where <field> is
typed as [<current_type>] — i.e. the field's declared type is the same type
currently being rendered. These are self-referential flag tuples set per-object
to enable conditional access for a particular permission. The self-referential type
is the sole criterion; no other heuristic (naming pattern, presence of or
terms, etc.) is needed. Examples from v1_past_meeting:
define past_meeting_for_participant_recording_view: [v1_past_meeting]
define past_meeting_for_attendee_recording_view: [v1_past_meeting]
define past_meeting_for_host_recording_view: [v1_past_meeting]
define recording_viewer: [user:*] or organizer or auditor
or invitee from past_meeting_for_participant_recording_view
or attendee from past_meeting_for_attendee_recording_view
or host from past_meeting_for_host_recording_view
For each such <rel> from <field> term in the relation being computed:
- Check whether
<field>'s declared type (fromdefine <field>: [<type>]) is the same as the current type. If yes, it is a self-referential conditional field — proceed with step 2. - The
<rel>named in the expression is a direct-grant column on the same type. Mark 🟡 for that column (and apply the upward reachability propagation rule: all columns that include<rel>viaoralso get 🟡, unless they already have ✅ from a different source). - Do not escalate 🟡 to ✅ — self-referential conditional fields always yield conditional access only.
Worked example — v1_past_meeting#recording_viewer JTBD:
The JTBD "View past meeting recordings" is on recording_viewer. Its define:
[user:*] or organizer or auditor
or invitee from past_meeting_for_participant_recording_view
or attendee from past_meeting_for_attendee_recording_view
or host from past_meeting_for_host_recording_view
organizeris an indirect-only column. Upward set from organizer:auditorsaysor organizer(implicitly via upward chain). Organizer gets ✅; auditor's upward set also yields ✅ for auditor.auditoris an indirect-only column. Gets ✅ directly.invitee from past_meeting_for_participant_recording_view:past_meeting_for_participant_recording_viewis declared[v1_past_meeting]— same as the current type → self-referential conditional → Invitee gets 🟡.attendee from past_meeting_for_attendee_recording_view: Attendee gets 🟡.host from past_meeting_for_host_recording_view: Host gets 🟡.[user:*]→ Everyone column gets 🟡.
Result row: | View past meeting recordings | ✅ | ✅ | 🟡 | 🟡 | 🟡 | 🟡 |
(columns: Organizer, Auditor, Host, Invitee, Attendee, Everyone)
Cross-type fields — unconditional or halt:
If <field>'s declared type is a different type than the current one, it
is a cross-type link. There are two sub-cases:
Primary parent links (e.g.
project,committee,meetingonv1_past_meeting) — these are already handled by the standard upward reachability algorithm in Step 2c. No special treatment needed here.Any other cross-type field whose semantics are not covered by the upward reachability algorithm — halt and flag:
⚠ Unhandled cross-type field
<field>(type<other_type>) in<current_type>#<relation>. Manual review required before rendering.Do not emit a blank cell, a 🟡, or a ✅ for that column. Leave the entire type's table unrendered and continue to the next type. This pattern has no current instances in the model; if one appears, the skill must be extended before it can be rendered correctly.
For the Everyone column ([user:*]):
For each relation R whose define contains [user:*], build R's own upward
reachability set using the same rule. Mark 🟡 if the JTBD appears in the
JTBD list of R itself or any relation in R's upward reachability set.
Worked example — project type:
Relations and their defines (simplified):
writer: [user] or owner or writer from parent
JTBDs: Create a vote, Manage key contacts, Create committees/meetings/lists,
Update project settings, Create & update a project
auditor: [user, team#member] or writer or auditor from parent
JTBDs: View project settings, View membership tiers,
View memberships & member companies, View membership key contacts
meeting_coordinator: [user]
JTBDs: (none)
viewer: [user:*] or auditor or auditor from parent
JTBDs: View a project, View project meeting count
Named-permission columns: writer, auditor, meeting_coordinator.
Everyone column: yes (viewer has [user:*]).
Upward reachability:
writer: which relations sayor writer? →auditordoes. Which sayor auditor? →viewerdoes (but viewer is not a named-permission column). So writer's upward set = {auditor, viewer}. Writer column JTBDs = writer's own ∪ auditor's own ∪ viewer's own = all JTBDs.auditor: which relations sayor auditor? →viewerdoes. Auditor's upward set = {viewer}. Auditor column JTBDs = auditor's own ∪ viewer's own = auditor JTBDs + viewer JTBDs.meeting_coordinator: nothing includes meeting_coordinator. Upward set = {}. Meeting coordinator column JTBDs = (none) → all cells empty.Everyone (
viewerhas[user:*]): viewer's upward set = {} (nothing includes viewer). Everyone JTBDs = viewer's own a project, View project meeting count}.
Result table (JTBD rows ordered by semantic priority — base object first, then settings, then attributes, then child resource creation):
| Project Writer | Project Auditor (full read) | Project Meeting Coordinator | Everyone | |
|---|---|---|---|---|
| View a project | ✅ | ✅ | 🟡 | |
| View project meeting count | ✅ | ✅ | 🟡 | |
| View project membership key contacts | ✅ | ✅ | ||
| View project memberships & member companies | ✅ | ✅ | ||
| View project membership tiers | ✅ | ✅ | ||
| View project settings | ✅ | ✅ | ||
| Create a vote | ✅ | |||
| Manage project membership key contacts | ✅ | |||
| Create project committees, meetings & mailing lists | ✅ | |||
| Update project settings | ✅ | |||
| Create & update a project | ✅ |
Note: "View a project" and "View project meeting count" appear even though
they come from viewer which has no [user] grant — all JTBDs are always
shown as rows.
Note: write JTBDs ("Create a vote" etc.) do NOT appear in the Everyone
column because viewer does not include writer — the chain is
viewer → auditor → writer only when you are a privileged user, not when
you are anonymous. The upward reachability for viewer stops at viewer
itself (nothing includes viewer).
2d — Omit the Everyone column only when no type-level relation has [user:*]
If no relation in the type has [user:*] in its define expression, omit the
Everyone column entirely. The Everyone column is ALWAYS the rightmost.
2e — Collect team-suffix annotations
Before writing any output, scan the existing PERMISSIONS.md for
#### Permission Inheritance bullets that contain one or more "… Team"
references — i.e. any phrase matching \b\w[\w\s]*Team\b within a bullet
line (e.g. "global LF Staff Team", "global Product Support Team").
For each such bullet, record the relation name (the bold or bold-italic
text at the start of the bullet) and the full list of "… Team" phrases
found in that bullet. Key these by (<section-heading>, <normalized-relation-name>),
where <section-heading> is the nearest preceding ### heading and
<normalized-relation-name> is the relation display name with all Markdown
emphasis characters (*, _) stripped and then lowercased (e.g. ***Owner***
→ owner, **Writer** → writer).
This ensures hand-curated global team assignments survive regeneration without requiring any special markup in the file.
Step 3 — Build Permission Inheritance sections
For each visible type, for each direct-grant relation (has [user],
not hidden) and each indirect-only column, emit a bullet when the
relation's own define expression contains one or more non-peer sources:
a direct <rel> from <field> term where <field> resolves to a
different type (i.e. a field whose type annotation is not the current
type), and/or any type restriction in [...] other than user/user:*.
Indirect-only columns always have at least one such non-peer source by definition, so they will always produce a bullet. Their bullet uses the same format as direct-grant bullets — italicize the relation display name to match the italicized column header:
- ***<rel display name>***: inherited from <Source Type Display Name> <Relation Display Name>
Direct-grant bullet format (unchanged):
- **<rel display name>**: inherited from <Source Type Display Name> <Relation Display Name>
Rules:
- Only examine the define expression of the relation itself — do not
follow
or <peer>chains to discover cross-type sources that belong to a peer relation. Each relation's bullet describes only what is written directly in that relation's define. - Parent-of-same-type (
<rel> from parent) counts as cross-type whenparentholds the current type (i.e. it is a recursive parent link) — mention it as "inherited from parent <Type Display Name>". - Do not emit a bullet for
[user:*]public-access — this is already communicated by the Everyone column in the table. - Do not mention same-type
or <peer>inclusions — these are already visible from the ✅ columns in the table. - Omit a bullet entirely if the relation has no direct cross-type sources.
- Omit the entire
#### Permission Inheritancesub-section if no bullets are generated for any relation in that type.
Do not include verbatim OpenFGA syntax in the output. No backtick
expressions like `writer from project` or `or organizer` should
appear anywhere in PERMISSIONS.md. Describe inheritance in plain English
only (e.g. "inherited from Project Writer", "inherited from parent Project").
When multiple direct cross-type sources exist for one relation, list them on a single bullet separated by commas.
Team-phrase lookup: For each bullet emitted, look up
(<section-heading>, <normalized-relation-name>) in the map collected in
Step 2e, where <normalized-relation-name> is the relation display name with
* and _ stripped and lowercased. If a match is found, append the preserved
"… Team" phrases to the bullet text, separated by a comma. If the relation has
no model-derived sources at all (i.e. the bullet would otherwise be omitted),
still emit the bullet with only the team phrases as its content.
Step 4 — Write PERMISSIONS.md
File structure:
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->
<!-- generated-intro
This file is generated automatically from
charts/lfx-platform/files/model.fga
by the render-permissions agent skill. Do not edit the sections below by hand.
Run .agents/skills/render-permissions/SKILL.md to regenerate after any model change.
-->
# LFX Self Service Platform Permissions
<intro — preserved if existing, else default below>
## Object types
### <Type display name>
| | <col1> | <col2> | ... | *Everyone* |
|---|---|---|---|---|
| <jtbd> | ✅ | | ✅ | 🟡 |
#### Permission Inheritance
- **<rel>**: inherited from ...
---
Use --- as a visual divider between type sections.
For types with no visible columns and no Everyone column (no direct [user]
or [user:*] grants at all), write a short prose paragraph explaining how
access is inherited, and omit the table and inheritance sub-section.
Table header row: The first cell of the header row is blank (no "Job to Be Done" text). Columns follow the ordering rule below, with Everyone always last.
Column ordering rule: Apply this sort across all columns:
- Indirect-only columns (italicized) — leftmost of all, ordered by descending privilege: owner → writer → organizer → auditor → any remaining (file order among themselves)
- owner (if present, direct-grant)
- writer (if present, direct-grant)
- auditor (if present, direct-grant)
- Any remaining direct-grant columns whose raw relation name does not
match
member,participant, orsubscriber— in file order among themselves - member, participant, subscriber (whichever are present, direct-grant) — rightmost among direct-grant columns, in file order among themselves
- Everyone — always the absolute rightmost column
Default intro (use only if file is new or has no existing intro after
the <!-- generated-intro ... --> block):
This document describes the permissions model for the LFX Self Service
Platform. Each section below represents an object type and its permissions.
## Legend
- "**Permission Name**" column headings are permissions available as directly-assignable grants per object of this type (may also be inherited; see lists below tables)
- "**_Italicized Permission Name_**" headings are permissions only available per object via inheritance (_not_ directly assignable)
- ✅ this permission unconditionally allows the action in this row on that object
- 🟡 access is conditional (depends on data/settings stored in the object itself)
Preserving the intro: The <!-- generated-intro ... --> comment block and
the H1 heading are always re-written. Everything between the H1 and the
## Object types heading is the intro and must be preserved if it already
exists.
Step 5 — Verify
After writing, re-read PERMISSIONS.md and confirm:
- Count of
###headings matches the number of non-hidden types. - Relations with
<rel> from <field>terms where<field>is declared as[<current_type>](self-referential) show 🟡 for the named<rel>column and its upward reachability set — not blank and not ✅. - No cross-type field (declared as
[<other_type>]) outside the standard upward reachability algorithm was silently rendered — if one was found, rendering halted and a ⚠ flag was emitted instead. - Every visible type with at least one visible column or Everyone column has a table.
- No
[user:*]-only relation appears as a direct-grant or indirect-only column. - Every type with at least one
[user:*]relation has an Everyone column (italicized header). - Every relation with
[user:*]in its define does NOT get a public-access bullet (the Everyone column covers this). - Indirect-only columns appear leftmost, before all direct-grant columns, in descending privilege order (owner → writer → organizer → auditor → remaining in file order).
- Indirect-only column headers are italicized (e.g.
*Writer*,*Auditor*). - Indirect-only columns with zero ✅ cells are omitted entirely.
- Permission Inheritance bullets appear for both direct-grant relations (has
[user], not hidden) and indirect-only columns, when they have direct cross-type<rel> from <field>terms and/or non-user/user:*type restrictions in their own define — no peer-chain traversal. - Indirect-only bullets use bold-italic name (e.g.
- ***Auditor***: inherited from ...); direct-grant bullets use bold name (e.g.- **Writer**: inherited from ...). - JTBD rows within each table follow the semantic ordering rule: base object first, settings next, attributes in Read → Update → Delete order, child resource creation last.
- ALL JTBDs from ALL relations of a type appear as rows (including viewer/public JTBDs).
- Column ordering rule applied: indirect-only (owner → writer → organizer → auditor → other file order) → owner → writer → auditor → other direct-grant (file order) → member/participant/subscriber (file order) → Everyone rightmost.
- The Everyone column header is italicized.
- The Everyone column is always rightmost.
- Writer columns show ✅ for auditor JTBDs (because auditor includes writer, so writers have auditor access).
- Auditor columns do NOT show ✅ for writer-only JTBDs (auditors are not writers).
- The Everyone column shows 🟡 only for JTBDs from the
[user:*]relation's own upward reachability set (not from privileged permissions that the [user:*] relation happens to include downward). - The table header first cell is blank (no "Job to Be Done" text).
- No same-type peer relations appear in Permission Inheritance bullets.
- No verbatim OpenFGA syntax (backtick expressions like
`writer from project`) appears anywhere in the file. - The
<!-- generated-intro ... -->block is present at the top. - The H1
# LFX Self Service Platform Permissionsis present. - The
## Object typesheading is used (not## Objects supporting grant assignmentor## Entities). - The intro block is unchanged (if it existed before).
- Every "… Team" phrase that appeared in a Permission Inheritance bullet in the previous file is present in the corresponding bullet in the output (same section, same relation).
Report: types rendered, total columns (excluding Everyone), total JTBD rows.