Skip to content

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, and skill_package_co_owners tables. 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.

Every shareable resource in Cinatra belongs to one of four extension kinds:

KindWhat it isPrimary owner column (legacy)
agent_runA single execution of an agent templateagent_runs.run_by
agent_templateA registered agent definitionagent_templates.creator_id
skill_packageA skill package (a bundle of skills)skill_packages.payload.installedByUserId
skillAn 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).

Both keyed on (resource_kind, resource_id) composite PK:

resource_kind text NOT NULL -- 'agent_run' | 'agent_template' | 'skill_package' | 'skill'
resource_id text NOT NULL -- the kind-specific resource id
user_id text NOT NULL -- better-auth user id (FK ON DELETE CASCADE)
granted_by text NOT NULL -- better-auth user id of the granter
granted_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).

resource_kind text NOT NULL
resource_id text NOT NULL
policy jsonb NOT NULL -- AgentAuthPolicySchema-validated
installed_by_user_id text -- FK ON DELETE SET NULL
updated_at timestamptz DEFAULT now()
PRIMARY KEY (resource_kind, resource_id)
CHECK resource_kind IN (...)

Lives in packages/extensions/src/:

  • permissions-store.ts — raw SQL helpers on the two polymorphic tables. ON CONFLICT DO UPDATE with COALESCE for selective field preservation on policy upsert. The syncLegacyCoOwnersFromCanonical helper 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 check
    • extraEditors(resourceId) — additional editor user ids unioned into the auth gate (e.g. agent_run adds runBy; skill adds parent package’s installer + co-owners)
    • allowSharing(resourceId) — optional gate that runs before adding a co-owner (e.g. agent_run blocks when allowRunSharing === false)
    • afterPolicyWrite(resourceId, policy) — transitional dual-write to the kind’s legacy policy location
    • afterCoOwnerAdd / afterCoOwnerRemove — transitional snapshot-sync to the kind’s legacy co-owners table
    • afterInstallerSet(resourceId, installerUserId) — transitional dual-write to the kind’s legacy installer pointer
    • selfRemoveRedirect — page-level redirect target after self-removal
  • permissions-actions.ts"use server" kind-discriminated server actions. Single canEditExtension gate consults admin → installer → polymorphic co-owners → extraEditors hook. All exported actions:

    • saveExtensionAccessPolicy(kind, resourceId, policy) — zod-validates policy against AgentAuthPolicySchema before 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 checks
    • searchExtensionCoOwnerCandidates(kind, resourceId | null, query, page) — paginated humans-only user search; null resourceId means upload-flow mode (admin-only gate)
    • addExtensionCoOwner(kind, resourceId, targetUserId) — humans-only direct-add guard preserved from Phase 269 Wave 1 fix
    • removeExtensionCoOwner(kind, resourceId, targetUserId)
    • readExtensionAccessPolicyAction(kind, resourceId) — used by page-data loaders + post-install flows

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).

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.

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.