Extension Permissions Architecture
The kind-discriminated permission system for all Cinatra extension kinds
(agent_run, agent_template, skill_package, skill).
Transitional notice (2026-05-12): the polymorphic backend described below is the canonical source of truth, but several legacy per-kind readers (orchestrator-screens, MCP handlers, page-data loaders, store enforce-access predicates) still query the legacy
run_co_owners,skill_co_owners, andskill_package_co_ownerstables. Dual-write hooks (syncLegacyCoOwnersFromCanonical) keep those tables in sync with canonical on every write. A follow-up phase migrates the readers and only then drops the legacy tables.
The four kinds
Section titled “The four kinds”Every shareable resource in Cinatra belongs to one of four extension kinds:
| Kind | What it is | Primary owner column (legacy) |
|---|---|---|
agent_run | A single execution of an agent template | agent_runs.run_by |
agent_template | A registered agent definition | agent_templates.creator_id |
skill_package | A skill package (a bundle of skills) | skill_packages.payload.installedByUserId |
skill | An individual skill inside a package | (inherits package’s installer) |
These are enumerated as a text + CHECK constraint on the polymorphic
tables — adding a new kind is add the value to the IN-list rather than
ALTER TYPE (so future kinds like connector or mcp_server are a
schema-light change, not a migration sentinel).
The two polymorphic tables
Section titled “The two polymorphic tables”Both keyed on (resource_kind, resource_id) composite PK:
cinatra.extension_co_owners
Section titled “cinatra.extension_co_owners”resource_kind text NOT NULL -- 'agent_run' | 'agent_template' | 'skill_package' | 'skill'resource_id text NOT NULL -- the kind-specific resource iduser_id text NOT NULL -- better-auth user id (FK ON DELETE CASCADE)granted_by text NOT NULL -- better-auth user id of the grantergranted_at timestamptz DEFAULT now()PRIMARY KEY (resource_kind, resource_id, user_id)INDEX on (user_id) and (resource_kind, resource_id)CHECK resource_kind IN (...)No FK on resource_id — it’s polymorphic, so app-layer + per-kind DELETE
hooks handle cleanup. Orphan reads return empty co-owner sets (auth gate
fails closed for the now-deleted resource).
cinatra.extension_access_policy
Section titled “cinatra.extension_access_policy”resource_kind text NOT NULLresource_id text NOT NULLpolicy jsonb NOT NULL -- AgentAuthPolicySchema-validatedinstalled_by_user_id text -- FK ON DELETE SET NULLupdated_at timestamptz DEFAULT now()PRIMARY KEY (resource_kind, resource_id)CHECK resource_kind IN (...)The generic backend
Section titled “The generic backend”Lives in packages/extensions/src/:
-
permissions-store.ts— raw SQL helpers on the two polymorphic tables.ON CONFLICT DO UPDATEwithCOALESCEfor selective field preservation on policy upsert. ThesyncLegacyCoOwnersFromCanonicalhelper does the two-statement snapshot sync (DELETE not-in-canonical, INSERT-on-conflict-do-nothing) into a legacy per-kind table. -
permissions-kind-hooks.ts— per-kind hooks registry. Hooks are lazy-loaded so this module doesn’t pull every kind’s store layer into the bundle unconditionally. Each kind implements:resourceExists(resourceId)— cheap existence checkextraEditors(resourceId)— additional editor user ids unioned into the auth gate (e.g.agent_runaddsrunBy;skilladds parent package’s installer + co-owners)allowSharing(resourceId)— optional gate that runs before adding a co-owner (e.g.agent_runblocks whenallowRunSharing === false)afterPolicyWrite(resourceId, policy)— transitional dual-write to the kind’s legacy policy locationafterCoOwnerAdd / afterCoOwnerRemove— transitional snapshot-sync to the kind’s legacy co-owners tableafterInstallerSet(resourceId, installerUserId)— transitional dual-write to the kind’s legacy installer pointerselfRemoveRedirect— page-level redirect target after self-removal
-
permissions-actions.ts—"use server"kind-discriminated server actions. SinglecanEditExtensiongate consults admin → installer → polymorphic co-owners →extraEditorshook. All exported actions:saveExtensionAccessPolicy(kind, resourceId, policy)— zod-validatespolicyagainstAgentAuthPolicySchemabefore any write or hook (security hardening: server actions get untyped JSON at runtime; the TS type isn’t trustworthy)setExtensionInstaller(kind, resourceId, installedByUserId)— admin-only, with resource-existence + humans-only target user checkssearchExtensionCoOwnerCandidates(kind, resourceId | null, query, page)— paginated humans-only user search;nullresourceId means upload-flow mode (admin-only gate)addExtensionCoOwner(kind, resourceId, targetUserId)— humans-only direct-add guard preserved from Phase 269 Wave 1 fixremoveExtensionCoOwner(kind, resourceId, targetUserId)readExtensionAccessPolicyAction(kind, resourceId)— used by page-data loaders + post-install flows
The generic widget
Section titled “The generic widget”Single client component at
src/components/extension-permissions-client.tsx:
<ExtensionPermissionsClient kind="agent_run" // or "agent_template" | "skill_package" | "skill" resourceId={run.id} canEdit={canEdit} initialPolicy={effectivePolicy} owner={runOwner} coOwners={coOwners} availableScopes={availableScopes} currentUserId={actorUserId} allowSharing={effectivePolicy.allowRunSharing} removeOwner={() => removeRunOwner(run.id)} // optional — agent_run only/>Binds the generic server actions to the underlying <PermissionsForm>
widget. The optional removeOwner prop lets a kind expose its own
primary-owner clear semantic (currently only agent_run exposes it —
agent_runs.run_by is on the run record itself, not on the polymorphic
policy table, with a last-co-owner-remaining guard).
Upload-time policy capture
Section titled “Upload-time policy capture”Both the ZIP and GitHub upload forms expose a <PermissionsFormDraft>
behind an “Configure access & ownership (advanced)” disclosure. The
captured draft is threaded into the install action (importAgentTemplate
for ZIP, installGitHubSkillExtension for GitHub), which calls
setExtensionInstaller + saveExtensionAccessPolicy + per-co-owner
addExtensionCoOwner through the generic backend. The disclosure
defaults closed so the one-click admin flow stays clean.
Failure handling
Section titled “Failure handling”Dual-write hook failures are caught, logged, and swallowed — the
polymorphic write has already succeeded and is canonical. Install-time
callers carry their own warnings[] array surfaced via toast.warning
so operators know when a non-fatal install-time permission write
failed. Edit-flow callers do NOT surface hook failures in their return
value; the next read of the legacy table (during the next add/remove
operation or after the reader migration completes) re-syncs via the
snapshot pattern.