Feature Specification: FGA-Projected Team Shares (Generic Module)
Feature Branch: prebuild/feat/fga-projected-team-shares (suggested)
Created: 2026-06-04
Status: Draft — spec only; no implementation in this PR
Depends on: Agent-skills OpenFGA-only team shares (landed or in flight on main)
Related:
docs/docs/specs/2026-06-03-unified-shareable-resource-rbac/— Model A (config = source of truth)docs/docs/specs/2026-06-04-fga-coverage-guarantee/— registry / default-deny invariants- Migration
agent_skill_openfga_reconcile_v1— one-off heal for skills (separate from this module)
Summary
Extract a reusable TypeScript module for resources whose team-share list lives only in OpenFGA, while Mongo (or upstream config) may still hold display metadata (e.g. visibility). The module centralizes:
- Reading the current shared-team set from FGA (not persistence)
- Reconciling next vs previous team slugs via existing
reconcileShareableResource - Stripping legacy
shared_with_teamsfrom persistence writes - Hydrating
shared_with_teamson API responses for editors
Agent skills are the reference implementation today (skill-team-grants.ts, route strip/hydrate, FGA read on PUT). This spec defines the generic layer and a follow-up PR that refactors skills onto it and optionally migrates dynamic agents (same orphan-tuple risk).
This is not a replacement for shareable-resource.ts (handleShareableResourceWrite), which implements Model A.
Problem
Two persistence models coexist:
| Model | Source of truth for team shares | Route helper | Example |
|---|---|---|---|
| A — Config-as-truth | Mongo / RAG config shared_with_teams | handleShareableResourceWrite | KB, MCP tool, (agents today) |
| B — FGA-as-truth | OpenFGA team:<slug>#member → <relation> → <object> | Ad hoc per resource | Agent skills (2026-06) |
Model B fixes a class of bugs Model A is prone to when config and FGA drift:
- PUT reconcile uses Mongo
shared_with_teamsaspreviousSharedTeamSlugs→ if Mongo was cleared but FGA still has tuples, revokes never run and private/global visibility leaks via staleteam:*#membergrants. - Gallery / discover checks FGA; editor shows Mongo → operators think sharing was removed when FGA still grants access.
Skills were fixed by reading previous teams from FGA and stopping Mongo writes. That logic is duplicated conceptually and should be one module parameterized by objectType + memberRelations (+ optional visibility → org-wide flags).
Goals
- G1: One module (
fga-projected-team-sharesor similar) implements Model B operations without forking per resource. - G2: Skills refactor onto the module with no behavioral change (existing tests green).
- G3: Document how dynamic agents (and optionally others) adopt Model B in later PRs, without mixing Model A and B in the same helper.
- G4: Preserve
reconcileShareableResourceas the single tuple-diff core (no second reconciler).
Non-goals (this feature)
- Changing Model A resources (KB, MCP tool) to FGA-only persistence — they remain on
shareable-resource.tsunless product explicitly requests it. - Replacing
buildAgentRelationshipTupleDiff(agent tool-caller edges,user:*global access). - Chat
sharing.shared_with_teams, skill hubs, or conversation sharing. - New OpenFGA model types or permission renames.
- Implementing dynamic-agent migration in the first PR (skills refactor only; agents phased).
Architecture
┌─────────────────────────────────────┐
│ reconcileShareableResource (core) │
│ openfga-owned-resources.ts │
└─────────────────┬───────────────────┘
│
Model A │ Model B (this spec)
┌───────────────────────────┴───────────────────────────┐
│ │
handleShareableResourceWrite fga-projected-team-shares
(persist owner + shared to config) (read previous from FGA;
│ strip shared from Mongo;
│ hydrate API responses)
▼ ▼
KB, MCP tool, … skill (refactor),
agent (future PR)
Per-resource adapter
Each resource supplies a small descriptor:
interface FgaProjectedResourceDescriptor {
objectType: string; // e.g. "skill", "agent"
memberRelations: readonly string[]; // e.g. ["user"], ["reader","user","caller"]
/** Relations counted when listing shared team slugs from tuples (default: memberRelations) */
teamShareRelations?: readonly string[];
}
Optional visibility adapter (skills only in v1):
interface VisibilityProjectedShares {
next: "private" | "team" | "global";
previous?: "private" | "team" | "global";
/** Maps visibility → nextTeamRefs / org-wide flags for reconcileShareableResource */
}
User Scenarios & Testing
User Story 1 — Generic module (Priority: P1)
As a platform engineer, I want a single module for FGA-projected team shares so I do not re-implement read/hydrate/strip/reconcile wrappers for each resource type.
Independent test: Unit tests drive a fake objectType: "skill" descriptor through read → reconcile → hydrate; skill integration tests still pass after refactor.
Acceptance:
- Given tuples
team:platform#member user skill:abc, WhenreadSharedTeamSlugsFromOpenFga({ objectType: "skill", ... }, "abc"), Then["platform"]is returned. - Given reconciliation disabled, When read is called, Then
[]without throwing. - Given a document with
shared_with_teamsin the payload, WhenstripProjectedFieldFromMongoDoc(doc, "shared_with_teams"), Then the field is omitted and update helpers emit$unset.
User Story 2 — Skills refactor (Priority: P1, same PR as module)
As an operator, I want agent-skills sharing behavior unchanged after the refactor so the skills gallery and editor remain correct.
Acceptance:
- Existing suites pass:
skill-team-grants.test.ts,agent-skill-openfga-reconcile.test.ts,route-rbac.test.ts,agent-skill-visibility.test.ts, import-zip tests. skill-team-grants.tsbecomes a thin facade (re-exports or delegates to generic module + skill visibility mapping).- No new Mongo writes of
shared_with_teamsonagent_skills.
User Story 3 — Dynamic agents adoption (Priority: P2, separate PR)
As a platform engineer, I want dynamic agents to read previous shared teams from FGA on PUT so demoting visibility or un-sharing teams revokes stale tuples even when Mongo shared_with_teams is empty or stale.
Acceptance (future PR):
- PUT
/api/dynamic-agentspassespreviousSharedTeamSlugsfrom FGA read, not only Mongo. - Document whether Mongo
shared_with_teamsremains for UI cache or is dropped (product decision in that PR). - Regression test: skill/agent pattern — remove team from share list with empty Mongo field but FGA still has tuple → reconcile deletes tuple.
User Story 4 — Documentation & drift guard (Priority: P2)
As a security reviewer, I can see which resources use Model A vs Model B and run a drift test so new routes do not persist shared_with_teams without opting into Model B strip/hydrate.
Acceptance:
- RBAC living docs (
docs/docs/security/rbac/or equivalent) add a "Persistence models" subsection. - Optional test: resources registered in
FGA_PROJECTED_RESOURCE_DESCRIPTORSmust not appear in a denylist of Mongoshared_with_teamswriters (lint or static list).
Functional Requirements
Module (ui/src/lib/rbac/fga-projected-team-shares.ts)
- FR-001: Export
readSharedTeamSlugsFromOpenFga(descriptor, objectId)— paginatedreadOpenFgaTuplesonobject, extract slugs fromteam:<slug>#memberwhererelationis inteamShareRelations(defaultmemberRelations). - FR-002: Export
extractTeamSlugsFromTuples(descriptor, objectId, tuples)— pure function; move logic fromteamSlugsFromSkillTuplesand generalize relation filter. - FR-003: Export
reconcileProjectedTeamShares(input)— resolve team refs (ObjectId/slug → slug via sharedresolveTeamSlugshelper, dedupe with existing teams collection query), callreconcileShareableResourcewithpreviousSharedTeamSlugs/nextSharedTeamSlugsfrom resolved refs. - FR-004: Support optional
visibilityblock on reconcile input mapping tonextTeamRefs = []when notteam, andsharedWithOrg/previousSharedWithOrgwhenglobal(skills parity). - FR-005: Export
stripProjectedFieldFromMongoDocandmongoUnsetProjectedField(fieldName)for consistent$unseton updates. - FR-006: Export
hydrateProjectedTeamShares(doc, descriptor, options)— whenoptions.teamVisibilityValuematches doc visibility (e.g."team"), attachshared_with_teamsfrom FGA; otherwise clear field on response. - FR-007:
resolveTeamSlugsMUST live in one place (move fromskill-team-grants.tsor sharedteam-slug-resolve.ts) and be reused by bulk grant helpers.
Skills facade
- FR-008:
reconcileSkillTeamShares/readSkillSharedTeamSlugsFromOpenFga/grantSkillsToTeamsremain as public API but delegate to FR-001–FR-003 (bulk grant may stay write-only). - FR-009:
hydrateAgentSkillTeamShares*delegate to FR-006.
Safety
- FR-010: When
OPENFGA_RECONCILE_ENABLEDis false, read returns[]and reconcile is a no-op (existing behavior). - FR-011: Invalid OpenFGA object ids MUST fail fast in reconcile (same as
buildShareableResourceTupleDiff). - FR-012: Module MUST NOT call
handleShareableResourceWriteor persistshared_with_teamsto config.
Out of scope for implementation PR
- FR-013 (future): Dynamic agents PUT FGA-read — User Story 3.
- FR-014 (future): RAG KB FGA-only — requires upstream RAG API contract change.
Edge Cases
- Reconciliation disabled: hydrate returns undefined/empty shares; reconcile no-op; routes still strip Mongo field on write.
- Visibility
teambut FGA empty: hydrate returnsundefinedor[]; editor may prompt user to re-select teams (existing UX). - Team ref is Mongo ObjectId: resolved to slug before reconcile; unknown ref kept as literal slug (existing skill behavior).
- Owner team on agents: Model B module handles
ownerTeamSlug+previousOwnerTeamSlugwhen descriptor includes them; agent-specificuser:*and tool edges stay inopenfga-agent-tools.ts. - Concurrent PUTs: previous set read from FGA at start of reconcile (last writer wins on FGA, same as config-as-truth today).
- Migration backfill: remains per-resource (
agent_skill_openfga_reconcile_v1); generic module does not replace admin migrations.
Success Criteria
- SC-001: Adding a new Model B resource requires only a descriptor + route wiring (strip/hydrate/reconcile), not copy-paste of tuple pagination.
- SC-002: Zero behavioral diff for agent skills (automated tests + manual: private skill not discoverable by non-owner after demote).
- SC-003:
shareable-resource.tsandfga-projected-team-shares.tsare both documented; no merged god-module. - SC-004: Dynamic-agent stale-tuple regression test added in the agents adoption PR (not blocking skills refactor).
PR Scope (recommended split)
| PR | Contents |
|---|---|
| PR 1 (this spec) | Spec + contracts only |
| PR 2 | Implement module + refactor skills + tests + docs snippet |
| PR 3 | Dynamic agents: FGA previous read, optional Mongo field removal, tests |
| PR 4 (optional) | Admin migration generalization / registry entry in FGA coverage manifest |
References (current code)
| Concern | Today | After PR 2 |
|---|---|---|
| Tuple diff | reconcileShareableResource | unchanged |
| Skill reconcile | skill-team-grants.ts | facade → generic |
| Skill FGA read | readSkillSharedTeamSlugsFromOpenFga | generic read |
| Skill hydrate | agent-skill-visibility.ts | generic hydrate |
| Route strip | configs/route.ts local helpers | generic strip |
| Config-as-truth | shareable-resource.ts | unchanged |
Open Questions
- Agents PR: Drop Mongo
shared_with_teamsentirely or keep as read-only cache hydrated from FGA? - Descriptor registry: Const array
FGA_PROJECTED_DESCRIPTORSfor coverage manifest (tie to2026-06-04-fga-coverage-guarantee) — required in PR 2 or PR 4? - Bulk import paths:
grantSkillsToTeamswrite-only — stay on skill facade or move towriteOnlyTeamGrants(descriptor, ...)in generic module?
Resolved before implementation: Product owner for agents persistence (Q1); default FGA-only, hydrate on read to match skills.