Files
kandev/docs/specs/plugins/system-design/capability-approval.md
T
2026-09-15 23:16:06 +01:00

8.0 KiB

status, system, requirements, created, owners
status system requirements created owners
current plugins
REQ-PLUGINS-CAPABILITY-APPROVAL-001
REQ-PLUGINS-CAPABILITY-APPROVAL-002
REQ-PLUGINS-CAPABILITY-APPROVAL-003
REQ-PLUGINS-CAPABILITY-APPROVAL-004
REQ-PLUGINS-CAPABILITY-APPROVAL-005
REQ-PLUGINS-CAPABILITY-APPROVAL-006
2026-09-07
kandev

Plugin Capability Approval and Audit System Design

Purpose and boundaries

This design describes the generic plugin capability-approval substrate in apps/backend/internal/plugins: the host-minted installation identity, the workspace-scoped approval ledger, the append-only event trail, the fail-closed authorization decision, uninstall tombstones, the legacy v1 fence, and the audit receipt primitives. Higher-level surfaces (approval UI, coordinator flows, host receipt persistence) consume this substrate without being part of it.

Requirement mapping

Requirement Design section
REQ-PLUGINS-CAPABILITY-APPROVAL-001 Identity and approval ledger
REQ-PLUGINS-CAPABILITY-APPROVAL-002 Append-only event trail
REQ-PLUGINS-CAPABILITY-APPROVAL-003 Fail-closed authorization
REQ-PLUGINS-CAPABILITY-APPROVAL-004 Tombstones and reinstall identity
REQ-PLUGINS-CAPABILITY-APPROVAL-005 Legacy v1 fence
REQ-PLUGINS-CAPABILITY-APPROVAL-006 Audit receipts

Identity and approval ledger

store.Record.InstallationID (apps/backend/internal/plugins/store/store.go) is the host-minted opaque principal for an installed plugin release. It is minted at install time and persisted with the record; it is never derived from the plugin id, package digest, workspace, or install path, so a reinstall necessarily produces a different identity.

CapabilityApproval (apps/backend/internal/plugins/approval_store.go) is the current approval row: (installation_id, workspace_id) is the ledger key, and at most one current row exists per pair. A grant replaces the previous current row for its pair. The row stores the exact manifest_digest the approval was granted against, the canonically sorted and deduplicated capability_ids, the active/revoked state, the human actor, the immutable human policy version, and the audit revision. HumanPolicyVersionImmutable records that all current approvals are granted under the one unversioned human deny policy.

Approval writes are admitted only at the exact next revision. A write naming any other revision fails with ErrApprovalRevisionConflict and leaves stored state unchanged (revokeIfRevision in approval_store.go). Reused audit ids are idempotent only for byte-identical inputs; a different input on the same audit id fails without changing stored state.

CanonicalCapabilityList (apps/backend/internal/plugins/approval.go) is the single canonicalizer for capability lists: trim, bounded length, no NUL, deduplicate, sort. It rejects human-reserved capabilities and any id that is not an exact host.v2 capability. ManifestCapabilityIDs derives the exact host.v2.read:<resource> / host.v2.write:<resource> capability set from an installed manifest, and ManifestCapabilityDigest derives its canonical digest. Service.validateApprovalManifest rejects a grant whose manifest digest does not match the installed manifest or that names capabilities the manifest does not declare: the approval can never be broader than the manifest.

Ledger state persists as approvals.json under the plugins directory through the approvalLedger type (load/migrate semantics in approval_store.go).

Append-only event trail

CapabilityApprovalEvent records every approval change after commit: installation, workspace, before/after revisions and digests, actor, reason, the CapabilityApprovalEventType (grant, narrow, revoke, upgrade_review), and the observed timestamp. Events are appended to the ledger; grants, narrows, revokes, and upgrade reviews never rewrite or remove earlier events. The API surface (apps/backend/internal/plugins/approval_api.go) exposes list/get so future audit consumers can replay authority history.

Fail-closed authorization

Service.authorizePluginCapability (approval_service.go) is the single decision path for a capability request. It evaluates in a fixed order and returns a typed ApprovalDecision with a stable ApprovalDenyReason:

  1. Structural validation (malformedAuthorizationRequestReason): bounded identifiers and digests, NUL-free, and an exact capability form. A malformed request denies as malformed_request (or unsupported_capability for a non-host.v2 form) before any lookup.
  2. Human-reserved capability check denies as human_reserved.
  3. Ledger lookup. A missing row denies as missing_capability_approval; an unavailable ledger denies as unavailable_capability. A foreign workspace intentionally reuses the missing-approval reason: a more specific cross-workspace reason would disclose that the installation exists in another workspace.
  4. Revoked state or a non-nil tombstone denies as capability_revoked.
  5. A requested revision different from the current row denies as stale_capability_revision.
  6. Manifest intersection (manifestIntersectionDenyReason): the installed manifest must still declare the capability under the digest the approval was granted against, otherwise unavailable_capability.
  7. The capability must appear in the approved list, otherwise undeclared_capability. Otherwise the decision is allowed and carries the audit identity.

Every denial is side-effect free: no Host operation runs inside the approval layer. The deny vocabulary is typed (ApprovalDenyReason constants in approval.go) so callers can match reasons machine-readably.

Tombstones and reinstall identity

Service.approvalTombstoneInstallation writes a tombstone for an installation identity at uninstall time. A tombstoned identity denies all later approvals and requests (ErrApprovalInstallationTombstoned, capability_revoked), permanently. Because the reinstall mints a new installation_id, no prior approval can attach to the new installation: authority cannot be silently inherited across an uninstall/reinstall cycle.

Legacy v1 fence

isExactHostV2Capability gates both grant admission and authorization. A capability id that is not an exact host.v2 capability is rejected from approval lists (CanonicalCapabilityList) and denied at authorization time as unsupported_capability. Legacy v1 manifest declarations keep their original runtime behavior but cannot be granted or exercised through this substrate.

Audit receipts

ApprovalReceipt (approval.go) is the bounded, deterministic metadata a future host adapter can persist on read or write receipts without carrying authority: installation, workspace, revision, capability id, request and method digests, audit id, result, and observed time. CanonicalApprovalDigest builds stable digest identities from trimmed, NUL-joined inputs. Receipt fields pass through the same safe-bounds helpers as the decision inputs, so a hostile identifier is truncated or redacted rather than propagated: the receipt is always bounded and cannot be coerced into authority.

Persistence and compatibility

The approval ledger file carries approvals, events, tombstones, and idempotency inputs with forward-compatible JSON decoding (missing maps initialize empty). Optional fields on store.Record (installation id) decode on older records without migration; a missing installation identity simply has no approvals. The substrate admits only HumanPolicyVersionImmutable today; the stored per-row policy version is the stable extension point for a future policy revision.

Observability

Decisions are pure typed results; the audit id and receipt give future adapters the exact metadata to persist. Events themselves are the observable authority history; there is no separate metric emission in this substrate.