feat(plugins): add generic approval ledger and receipts (#3238)

Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Alassane
2026-09-15 18:16:06 -04:00
committed by GitHub
parent 37cea5a8c2
commit cb9752b1a2
27 changed files with 3226 additions and 13 deletions
+136
View File
@@ -0,0 +1,136 @@
package plugins
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/kandev/kandev/internal/plugins/manifest"
)
// ApprovalDecision is the typed allow/deny result for the generic plugin
// authorization substrate. It is intentionally small and side-effect free:
// no Host operation runs here.
type ApprovalDecision struct {
Allowed bool
// Reason is stable and machine-readable when Allowed is false.
Reason ApprovalDenyReason
// AuditID is the immutable audit identity attached to the decision.
AuditID string
// Receipt contains the safe metadata a future exact Host adapter can use
// when persisting an audit/read receipt.
Receipt ApprovalReceipt
}
// ApprovalDenyReason is the stable typed denial vocabulary used by the
// generic plugin approval layer.
type ApprovalDenyReason string
var ErrApprovalInvalidIdentifier = errors.New("plugins: approval identifier contains NUL")
const (
maxCapabilityCount = 1024
maxCapabilityIDLength = 256
maxApprovalIDLength = 1024
maxAuthorizationDataSize = 4096
)
// A foreign workspace (an installation with no approval in the requested
// workspace) intentionally reuses ApprovalDenyMissingApproval rather than a
// distinct reason: returning a different, more specific reason for "wrong
// workspace" than for "no approval at all" would itself disclose that the
// installation exists in another workspace, violating the fail-closed,
// no-target-disclosure requirement for cross-workspace lookups.
const (
ApprovalDenyMissingApproval ApprovalDenyReason = "missing_capability_approval"
ApprovalDenyStaleRevision ApprovalDenyReason = "stale_capability_revision"
ApprovalDenyRevokedApproval ApprovalDenyReason = "capability_revoked"
ApprovalDenyHumanReserved ApprovalDenyReason = "human_reserved"
ApprovalDenyForeignInstallation ApprovalDenyReason = "foreign_installation"
ApprovalDenyUnsupportedCapability ApprovalDenyReason = "unsupported_capability"
ApprovalDenyUndeclaredCapability ApprovalDenyReason = "undeclared_capability"
ApprovalDenyUnavailableCapability ApprovalDenyReason = "unavailable_capability"
ApprovalDenyMalformedRequest ApprovalDenyReason = "malformed_request"
)
// ApprovalReceipt is the bounded, deterministic metadata future Host adapters
// can attach to read/write receipts without carrying authority themselves.
type ApprovalReceipt struct {
InstallationID string `json:"installation_id"`
WorkspaceID string `json:"workspace_id"`
Revision uint64 `json:"revision"`
CapabilityID string `json:"capability_id"`
RequestDigest string `json:"request_digest"`
MethodDigest string `json:"method_digest"`
AuditID string `json:"audit_id"`
Result string `json:"result"`
ObservedAt time.Time `json:"observed_at"`
}
// CanonicalApprovalDigest returns a stable digest for receipt identity. The
// inputs are normalized to keep the output deterministic and to avoid
// accidentally treating whitespace as authority.
func CanonicalApprovalDigest(parts ...string) string {
normalized := make([]string, len(parts))
for i, part := range parts {
normalized[i] = strings.TrimSpace(part)
}
sum := sha256.Sum256([]byte(strings.Join(normalized, "\x00")))
return hex.EncodeToString(sum[:])
}
// CanonicalCapabilityList returns a copy of caps sorted and deduplicated.
func CanonicalCapabilityList(caps []string) ([]string, error) {
if len(caps) > maxCapabilityCount {
return nil, fmt.Errorf("plugins: too many capabilities")
}
out := make([]string, 0, len(caps))
seen := make(map[string]struct{}, len(caps))
for _, raw := range caps {
capability := strings.TrimSpace(raw)
if capability == "" || len(capability) > maxCapabilityIDLength || strings.ContainsRune(capability, '\x00') {
return nil, errors.New("plugins: empty capability")
}
if isHumanReservedCapability(capability) {
return nil, fmt.Errorf("plugins: Human-reserved capability %q cannot be approved", capability)
}
if !isExactHostV2Capability(capability) {
return nil, fmt.Errorf("plugins: capability %q is not an exact host.v2 capability", capability)
}
if _, ok := seen[capability]; ok {
continue
}
seen[capability] = struct{}{}
out = append(out, capability)
}
sort.Strings(out)
return out, nil
}
// ManifestCapabilityDigest is the canonical digest of the H6 capability set
// derived from an installed manifest. Legacy v1 declarations retain their
// original RPC behavior, but cannot themselves satisfy H6 authorization.
func ManifestCapabilityDigest(m manifest.Manifest) string {
canonical, err := ManifestCapabilityIDs(m)
if err != nil {
return ""
}
return CanonicalApprovalDigest(canonical...)
}
// ManifestCapabilityIDs returns the exact capability IDs declared by an
// installed manifest in canonical order.
func ManifestCapabilityIDs(m manifest.Manifest) ([]string, error) {
caps := make([]string, 0, len(m.Capabilities.APIRead)+len(m.Capabilities.APIWrite))
for _, resource := range m.Capabilities.APIRead {
caps = append(caps, "host.v2.read:"+strings.TrimSpace(resource))
}
for _, resource := range m.Capabilities.APIWrite {
caps = append(caps, "host.v2.write:"+strings.TrimSpace(resource))
}
return CanonicalCapabilityList(caps)
}
@@ -0,0 +1,59 @@
package plugins
import "time"
// ListCapabilityApprovals returns the current approval rows for one installed
// plugin identity.
func (s *Service) ListCapabilityApprovals(installationID string) ([]CapabilityApprovalDTO, error) {
rows, err := s.approvalListByInstallation(installationID)
if err != nil {
return nil, err
}
out := make([]CapabilityApprovalDTO, 0, len(rows))
for _, row := range rows {
out = append(out, approvalDTOFromCurrent(row))
}
return out, nil
}
// GetCapabilityApproval returns the current approval row for an
// installation/workspace pair.
func (s *Service) GetCapabilityApproval(installationID, workspaceID string) (CapabilityApprovalDTO, bool, error) {
row, ok, err := s.approvalCurrent(installationID, workspaceID)
if err != nil || !ok {
return CapabilityApprovalDTO{}, ok, err
}
return approvalDTOFromCurrent(row), true, nil
}
// AuthorizeCapability evaluates a single exact capability request against the
// current approval row and returns a typed allow/deny result.
func (s *Service) AuthorizeCapability(
installationID, workspaceID, capabilityID string, requestedRevision uint64, requestDigest, methodDigest string,
) ApprovalDecision {
return s.authorizePluginCapability(installationID, workspaceID, capabilityID, requestedRevision, requestDigest, methodDigest)
}
// GrantCapabilityApproval records a workspace-scoped approval. revision is
// the exact next revision, and auditID is the stable idempotency identity.
func (s *Service) GrantCapabilityApproval(installationID, workspaceID string, revision uint64, manifestDigest string, capabilityIDs []string, actor, reason, auditID string) (CapabilityApprovalDTO, error) {
row, err := s.approvalGrant(installationID, workspaceID, revision, manifestDigest, capabilityIDs, actor, reason, auditID)
if err != nil {
return CapabilityApprovalDTO{}, err
}
return approvalDTOFromCurrent(row), nil
}
// RevokeCapabilityApproval revokes only the supplied current revision. A
// stale caller receives ErrApprovalRevisionConflict and must read back state.
func (s *Service) RevokeCapabilityApproval(installationID, workspaceID string, expectedRevision uint64, actor, reason, auditID string) (CapabilityApprovalDTO, error) {
ledger := s.approvalLedger()
if ledger == nil {
return CapabilityApprovalDTO{}, ErrApprovalRevisionConflict
}
row, err := ledger.revokeIfRevision(installationID, workspaceID, expectedRevision, actor, reason, auditID, time.Now().UTC(), false)
if err != nil {
return CapabilityApprovalDTO{}, err
}
return approvalDTOFromCurrent(row), nil
}
@@ -0,0 +1,85 @@
package plugins
import (
"errors"
"testing"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/store"
)
func TestApprovalAPIExportsCurrentRowsAndDecision(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant: %v", err)
}
rows, err := svc.ListCapabilityApprovals("inst-1")
if err != nil {
t.Fatalf("ListCapabilityApprovals: %v", err)
}
if len(rows) != 1 || rows[0].WorkspaceID != "ws-1" {
t.Fatalf("rows = %#v", rows)
}
row, ok, err := svc.GetCapabilityApproval("inst-1", "ws-1")
if err != nil || !ok {
t.Fatalf("GetCapabilityApproval: ok=%v err=%v", ok, err)
}
if row.Revision != 1 {
t.Fatalf("row = %#v", row)
}
decision := svc.AuthorizeCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, "req", "method")
if !decision.Allowed {
t.Fatalf("decision = %#v", decision)
}
}
func TestApprovalAPIRevokeRetryReplaysOriginalResult(t *testing.T) {
svc := &Service{}
if err := svc.SetPluginsDir(t.TempDir()); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.GrantCapabilityApproval("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "grant-1"); err != nil {
t.Fatalf("grant: %v", err)
}
first, err := svc.RevokeCapabilityApproval("inst-1", "ws-1", 1, "human", "revoke", "revoke-1")
if err != nil {
t.Fatalf("first revoke: %v", err)
}
replayed, err := svc.RevokeCapabilityApproval("inst-1", "ws-1", 1, "human", "revoke", "revoke-1")
if err != nil {
t.Fatalf("exact retry: %v", err)
}
if replayed.Revision != first.Revision || replayed.UpdatedAt != first.UpdatedAt {
t.Fatalf("retry changed original result: first=%#v replayed=%#v", first, replayed)
}
if _, err := svc.RevokeCapabilityApproval("inst-1", "ws-1", 2, "human", "revoke", "revoke-1"); !errors.Is(err, ErrApprovalIdempotencyConflict) {
t.Fatalf("changed expected revision error = %v, want idempotency conflict", err)
}
}
func TestGrantCapabilityApprovalRequiresInstalledManifestBinding(t *testing.T) {
svc := &Service{registry: NewRegistry()}
if err := svc.SetPluginsDir(t.TempDir()); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
installed := &store.Record{
Manifest: manifest.Manifest{ID: "plugin-a", Capabilities: manifest.Capabilities{APIRead: []string{"tasks"}}},
InstallationID: "inst-1",
}
svc.registry.Add(installed)
_, err := svc.GrantCapabilityApproval("inst-1", "ws-1", 1, ManifestCapabilityDigest(installed.Manifest), []string{"host.v2.read:messages"}, "human", "grant", "audit-1")
if err == nil {
t.Fatal("GrantCapabilityApproval accepted a capability outside the installed manifest")
}
}
@@ -0,0 +1,61 @@
package plugins
import "time"
type CapabilityApprovalDTO struct {
InstallationID string `json:"installation_id"`
WorkspaceID string `json:"workspace_id"`
Revision uint64 `json:"revision"`
ManifestDigest string `json:"manifest_digest"`
CapabilityIDs []string `json:"capability_ids"`
State string `json:"state"`
HumanActor string `json:"human_actor"`
HumanPolicyVersion string `json:"human_policy_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CapabilityApprovalEventDTO struct {
AuditID string `json:"audit_id"`
InstallationID string `json:"installation_id"`
WorkspaceID string `json:"workspace_id"`
BeforeRevision uint64 `json:"before_revision"`
AfterRevision uint64 `json:"after_revision"`
BeforeDigest string `json:"before_digest"`
AfterDigest string `json:"after_digest"`
Actor string `json:"actor"`
Reason string `json:"reason"`
Type string `json:"type"`
ObservedAt time.Time `json:"observed_at"`
}
func approvalDTOFromCurrent(current CapabilityApproval) CapabilityApprovalDTO {
return CapabilityApprovalDTO{
InstallationID: current.InstallationID,
WorkspaceID: current.WorkspaceID,
Revision: current.Revision,
ManifestDigest: current.ManifestDigest,
CapabilityIDs: append([]string{}, current.CapabilityIDs...),
State: string(current.State),
HumanActor: current.HumanActor,
HumanPolicyVersion: current.HumanPolicyVersion,
CreatedAt: current.CreatedAt,
UpdatedAt: current.UpdatedAt,
}
}
func approvalEventDTOFromCurrent(event CapabilityApprovalEvent) CapabilityApprovalEventDTO {
return CapabilityApprovalEventDTO{
AuditID: event.AuditID,
InstallationID: event.InstallationID,
WorkspaceID: event.WorkspaceID,
BeforeRevision: event.BeforeRevision,
AfterRevision: event.AfterRevision,
BeforeDigest: event.BeforeDigest,
AfterDigest: event.AfterDigest,
Actor: event.Actor,
Reason: event.Reason,
Type: string(event.Type),
ObservedAt: event.ObservedAt,
}
}
@@ -0,0 +1,46 @@
package plugins
import (
"testing"
"time"
)
func TestApprovalDTOConversionCopiesState(t *testing.T) {
createdAt := time.Date(2026, time.August, 31, 1, 2, 3, 0, time.UTC)
updatedAt := createdAt.Add(time.Minute)
dto := approvalDTOFromCurrent(CapabilityApproval{
InstallationID: "inst-1",
WorkspaceID: "ws-1",
Revision: 7,
ManifestDigest: "digest",
CapabilityIDs: []string{"host.v2.read:tasks"},
State: ApprovalStateActive,
HumanActor: "human",
HumanPolicyVersion: "immutable",
CreatedAt: createdAt,
UpdatedAt: updatedAt,
})
if dto.InstallationID != "inst-1" || dto.WorkspaceID != "ws-1" || dto.Revision != 7 {
t.Fatalf("dto = %#v", dto)
}
}
func TestApprovalEventDTOConversionCopiesAuditIdentity(t *testing.T) {
when := time.Date(2026, time.August, 31, 1, 2, 3, 0, time.UTC)
dto := approvalEventDTOFromCurrent(CapabilityApprovalEvent{
AuditID: "audit-1",
InstallationID: "inst-1",
WorkspaceID: "ws-1",
BeforeRevision: 1,
AfterRevision: 2,
BeforeDigest: "a",
AfterDigest: "b",
Actor: "human",
Reason: "grant",
Type: CapabilityApprovalEventGrant,
ObservedAt: when,
})
if dto.AuditID != "audit-1" || dto.Type != string(CapabilityApprovalEventGrant) {
t.Fatalf("dto = %#v", dto)
}
}
@@ -0,0 +1,117 @@
package plugins
import (
"testing"
"time"
)
func TestServiceApprovalQueriesReturnCurrentRows(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant ws-1: %v", err)
}
if _, err := svc.approvalGrant("inst-1", "ws-2", 1, "digest-b", []string{"host.v2.write:tasks"}, "human", "grant", "audit-2"); err != nil {
t.Fatalf("grant ws-2: %v", err)
}
rows, err := svc.approvalListByInstallation("inst-1")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(rows) != 2 {
t.Fatalf("rows = %#v, want two workspace approvals", rows)
}
}
func TestServiceApprovalRevokeBumpsRevisionAndDenies(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant: %v", err)
}
if _, err := svc.approvalRevoke("inst-1", "ws-1", "human", "revoke", "audit-2"); err != nil {
t.Fatalf("revoke: %v", err)
}
decision := svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 2, "req", "method")
if decision.Allowed {
t.Fatalf("decision = %#v, want revoked approval denied", decision)
}
if decision.Reason != ApprovalDenyRevokedApproval {
t.Fatalf("reason = %q, want capability_revoked", decision.Reason)
}
}
func TestServiceApprovalRevokeRetryReplaysOriginalResult(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant: %v", err)
}
first, err := svc.approvalRevoke("inst-1", "ws-1", "human", "revoke", "revoke-1")
if err != nil {
t.Fatalf("first revoke: %v", err)
}
replayed, err := svc.approvalRevoke("inst-1", "ws-1", "human", "revoke", "revoke-1")
if err != nil {
t.Fatalf("retry revoke: %v", err)
}
if replayed.Revision != first.Revision || !replayed.UpdatedAt.Equal(first.UpdatedAt) {
t.Fatalf("retry changed the original result: first=%#v replayed=%#v", first, replayed)
}
}
func TestServiceApprovalTombstoneRetainsStateOnReinstall(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant: %v", err)
}
if err := svc.approvalTombstoneInstallation("inst-1"); err != nil {
t.Fatalf("tombstone: %v", err)
}
rows, err := svc.approvalListByInstallation("inst-1")
if err != nil {
t.Fatalf("list after tombstone: %v", err)
}
if len(rows) != 1 || rows[0].State != ApprovalStateRevoked {
t.Fatalf("rows after tombstone = %#v", rows)
}
}
func TestApprovalReceiptAuditIDIsDeterministic(t *testing.T) {
got := CanonicalApprovalDigest("inst", "ws", "cap", "req", "method", "1")
if got == "" {
t.Fatal("CanonicalApprovalDigest() returned empty audit id")
}
}
func TestApprovalLedgerGrantStoresCapabilitySnapshot(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
approval, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks", "host.v2.write:tasks"}, "human", "grant", "audit-1", time.Now().UTC())
if err != nil {
t.Fatalf("grant: %v", err)
}
if len(approval.CapabilityIDs) != 2 {
t.Fatalf("approval capability ids = %#v", approval.CapabilityIDs)
}
}
@@ -0,0 +1,297 @@
package plugins
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/kandev/kandev/internal/plugins/store"
)
// approvalLedger returns the ledger wired by SetPluginsDir. Callers handle a
// nil result (e.g. a Service constructed directly in tests without a plugins
// directory).
func (s *Service) approvalLedger() *approvalLedger {
return s.approvals
}
func (s *Service) approvalGrant(installationID, workspaceID string, revision uint64, manifestDigest string, capabilityIDs []string, actor, reason, auditID string) (CapabilityApproval, error) {
ledger := s.approvalLedger()
if ledger == nil {
return CapabilityApproval{}, fmt.Errorf("plugins: approval ledger not configured")
}
canonical, err := CanonicalCapabilityList(capabilityIDs)
if err != nil {
return CapabilityApproval{}, err
}
if err := s.validateApprovalManifest(installationID, manifestDigest, canonical); err != nil {
return CapabilityApproval{}, err
}
return ledger.grant(installationID, workspaceID, revision, manifestDigest, canonical, actor, reason, auditID, time.Now().UTC())
}
func (s *Service) validateApprovalManifest(installationID, manifestDigest string, capabilityIDs []string) error {
if s.registry == nil {
return nil
}
installed := s.installedRecordByInstallationID(installationID)
if installed == nil {
return fmt.Errorf("plugins: approval installation not found")
}
if manifestDigest != ManifestCapabilityDigest(installed.Manifest) {
return fmt.Errorf("plugins: approval manifest digest does not match installed manifest")
}
declared, err := ManifestCapabilityIDs(installed.Manifest)
if err != nil {
return err
}
declaredSet := make(map[string]struct{}, len(declared))
for _, capability := range declared {
declaredSet[capability] = struct{}{}
}
for _, capability := range capabilityIDs {
if _, ok := declaredSet[capability]; !ok {
return fmt.Errorf("plugins: capability %q is not declared by installed manifest", capability)
}
}
return nil
}
func (s *Service) approvalRevoke(installationID, workspaceID, actor, reason, auditID string) (CapabilityApproval, error) {
ledger := s.approvalLedger()
if ledger == nil {
return CapabilityApproval{}, fmt.Errorf("plugins: approval ledger not configured")
}
current, ok, err := ledger.get(installationID, workspaceID)
if err != nil || !ok {
if err != nil {
return CapabilityApproval{}, err
}
return CapabilityApproval{}, fmt.Errorf("plugins: approval not found")
}
return ledger.revokeIfRevision(installationID, workspaceID, current.Revision, actor, reason, auditID, time.Now().UTC(), true)
}
func (s *Service) approvalTombstoneInstallation(installationID string) error {
ledger := s.approvalLedger()
if ledger == nil {
return nil
}
return ledger.tombstoneInstallation(installationID, time.Now().UTC())
}
func (s *Service) approvalCurrent(installationID, workspaceID string) (CapabilityApproval, bool, error) {
ledger := s.approvalLedger()
if ledger == nil {
return CapabilityApproval{}, false, fmt.Errorf("plugins: approval ledger not configured")
}
return ledger.get(installationID, workspaceID)
}
func (s *Service) approvalListByInstallation(installationID string) ([]CapabilityApproval, error) {
ledger := s.approvalLedger()
if ledger == nil {
return nil, fmt.Errorf("plugins: approval ledger not configured")
}
return ledger.listByInstallation(installationID)
}
func (s *Service) authorizePluginCapability(installationID, workspaceID, capabilityID string, requestedRevision uint64, requestDigest, methodDigest string) ApprovalDecision {
reason, wellFormed := malformedAuthorizationRequestReason(installationID, workspaceID, capabilityID, requestDigest, methodDigest)
requestDigest = safeAuthorizationDigest(requestDigest)
methodDigest = safeAuthorizationDigest(methodDigest)
decision := ApprovalDecision{
Receipt: ApprovalReceipt{
InstallationID: safeReceiptIdentifier(installationID),
WorkspaceID: safeReceiptIdentifier(workspaceID),
Revision: requestedRevision,
CapabilityID: safeReceiptCapabilityID(capabilityID),
RequestDigest: requestDigest,
MethodDigest: methodDigest,
AuditID: CanonicalApprovalDigest(installationID, workspaceID, capabilityID, requestDigest, methodDigest, fmt.Sprint(requestedRevision)),
Result: "denied",
ObservedAt: time.Now().UTC(),
},
}
decision.AuditID = decision.Receipt.AuditID
if !wellFormed {
decision.Reason = reason
return decision
}
if isHumanReservedCapability(capabilityID) {
decision.Reason = ApprovalDenyHumanReserved
return decision
}
current, ok, err := s.approvalCurrent(installationID, workspaceID)
if err != nil {
decision.Reason = ApprovalDenyUnavailableCapability
return decision
}
if !ok {
decision.Reason = ApprovalDenyMissingApproval
return decision
}
if current.State != ApprovalStateActive || current.TombstonedAt != nil {
decision.Reason = ApprovalDenyRevokedApproval
return decision
}
if current.Revision != requestedRevision {
decision.Reason = ApprovalDenyStaleRevision
return decision
}
if reason, ok := s.manifestIntersectionDenyReason(installationID, capabilityID, current); !ok {
decision.Reason = reason
return decision
}
for _, allowed := range current.CapabilityIDs {
if allowed == capabilityID {
decision.Allowed = true
decision.Reason = ""
decision.Receipt.Result = "allowed"
decision.AuditID = decision.Receipt.AuditID
return decision
}
}
decision.Reason = ApprovalDenyUndeclaredCapability
return decision
}
// malformedAuthorizationRequestReason validates the structural shape of an
// authorization request before any capability/approval semantics are
// considered. ok is false when the request must be denied; reason is only
// meaningful when ok is false.
func malformedAuthorizationRequestReason(installationID, workspaceID, capabilityID, requestDigest, methodDigest string) (reason ApprovalDenyReason, ok bool) {
if !isBoundedApprovalIdentifier(installationID) || !isBoundedApprovalIdentifier(workspaceID) ||
!isBoundedAuthorizationData(requestDigest) || !isBoundedAuthorizationData(methodDigest) {
return ApprovalDenyMalformedRequest, false
}
if isUnsupportedCapabilityID(capabilityID) {
return ApprovalDenyUnsupportedCapability, false
}
return "", true
}
// manifestIntersectionDenyReason enforces that the current installed
// manifest still declares capabilityID under the same digest the approval
// was granted against. ok is false when the request must be denied; reason
// is only meaningful when ok is false. A nil registry (e.g. a Service
// constructed directly in tests) skips this check.
func (s *Service) manifestIntersectionDenyReason(installationID, capabilityID string, current CapabilityApproval) (reason ApprovalDenyReason, ok bool) {
if s.registry == nil {
return "", true
}
installed := s.installedRecordByInstallationID(installationID)
if installed == nil {
return ApprovalDenyForeignInstallation, false
}
if current.ManifestDigest != ManifestCapabilityDigest(installed.Manifest) {
return ApprovalDenyUnavailableCapability, false
}
if !manifestDeclaresCapability(installed, capabilityID) {
return ApprovalDenyUndeclaredCapability, false
}
return "", true
}
func (s *Service) installedRecordByInstallationID(installationID string) *store.Record {
for _, record := range s.registry.List() {
if record.InstallationID == installationID {
return record
}
}
return nil
}
func manifestDeclaresCapability(record *store.Record, capabilityID string) bool {
for _, resource := range record.Capabilities.APIRead {
if capabilityID == "host.v2.read:"+resource {
return true
}
}
for _, resource := range record.Capabilities.APIWrite {
if capabilityID == "host.v2.write:"+resource {
return true
}
}
return false
}
// isUnsupportedCapabilityID reports whether capabilityID cannot be an exact
// admitted capability class: empty, containing leading/trailing whitespace,
// or a wildcard/broad alias. This mirrors the canonicalization rejected at
// grant time (CanonicalCapabilityList) so an authorization request cannot
// bypass the same rule by presenting a broad identity directly.
func isUnsupportedCapabilityID(capabilityID string) bool {
return !isExactHostV2Capability(capabilityID) && !isHumanReservedCapability(capabilityID)
}
func isHumanReservedCapability(capabilityID string) bool {
resource := capabilityID
if _, suffix, ok := strings.Cut(capabilityID, ":"); ok {
resource = suffix
}
switch resource {
case "merge", "deploy", "release", "rewrite_history", "cross_workspace", "secret_scope_expand":
return true
default:
return false
}
}
func isExactHostV2Capability(capabilityID string) bool {
if len(capabilityID) == 0 || len(capabilityID) > maxCapabilityIDLength ||
strings.TrimSpace(capabilityID) != capabilityID || strings.ContainsAny(capabilityID, "*?\x00") {
return false
}
kind, resource, ok := strings.Cut(capabilityID, ":")
if !ok || !isHostV2CapabilityKind(kind) || !isExactCapabilityResource(resource) {
return false
}
return true
}
func isHostV2CapabilityKind(kind string) bool {
return kind == "host.v2.read" || kind == "host.v2.write"
}
func isExactCapabilityResource(resource string) bool {
if resource == "" || strings.Contains(resource, ":") {
return false
}
for _, r := range resource {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '_' {
return false
}
}
return true
}
func isBoundedApprovalIdentifier(value string) bool {
return value != "" && len(value) <= maxApprovalIDLength && strings.TrimSpace(value) == value && !strings.ContainsRune(value, '\x00')
}
func isBoundedAuthorizationData(value string) bool {
return value != "" && len(value) <= maxAuthorizationDataSize && !strings.ContainsRune(value, '\x00')
}
func safeAuthorizationDigest(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func safeReceiptIdentifier(value string) string {
if !isBoundedApprovalIdentifier(value) {
return ""
}
return value
}
func safeReceiptCapabilityID(value string) string {
if len(value) > maxCapabilityIDLength || strings.ContainsRune(value, '\x00') ||
(!isExactHostV2Capability(value) && !isHumanReservedCapability(value)) {
return ""
}
return value
}
@@ -0,0 +1,477 @@
package plugins
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
type ApprovalState string
const (
ApprovalStateActive ApprovalState = "active"
ApprovalStateRevoked ApprovalState = "revoked"
)
// HumanPolicyVersionImmutable is the only Human-policy version H6 currently
// admits. H0 defines the Human-reserved deny intersection (merge, deploy,
// release, history rewrite, cross-workspace access, secret-scope expansion)
// as an immutable, singular policy with no versioned variants yet. The field
// is stored on every approval row so a future policy revision has a stable
// place to record which policy an approval was granted under; it is not a
// caller-configurable input today.
const HumanPolicyVersionImmutable = "immutable"
type CapabilityApproval struct {
InstallationID string `json:"installation_id"`
WorkspaceID string `json:"workspace_id"`
Revision uint64 `json:"revision"`
ManifestDigest string `json:"manifest_digest"`
CapabilityIDs []string `json:"capability_ids"`
State ApprovalState `json:"state"`
HumanActor string `json:"human_actor"`
HumanPolicyVersion string `json:"human_policy_version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TombstonedAt *time.Time `json:"tombstoned_at,omitempty"`
}
type CapabilityApprovalEventType string
const (
CapabilityApprovalEventGrant CapabilityApprovalEventType = "grant"
CapabilityApprovalEventNarrow CapabilityApprovalEventType = "narrow"
CapabilityApprovalEventRevoke CapabilityApprovalEventType = "revoke"
CapabilityApprovalEventUpgradeReview CapabilityApprovalEventType = "upgrade_review"
)
type CapabilityApprovalEvent struct {
AuditID string `json:"audit_id"`
InstallationID string `json:"installation_id"`
WorkspaceID string `json:"workspace_id"`
BeforeRevision uint64 `json:"before_revision"`
AfterRevision uint64 `json:"after_revision"`
BeforeDigest string `json:"before_digest"`
AfterDigest string `json:"after_digest"`
Actor string `json:"actor"`
Reason string `json:"reason"`
Type CapabilityApprovalEventType `json:"type"`
ObservedAt time.Time `json:"observed_at"`
}
type approvalLedgerFile struct {
Approvals map[string]CapabilityApproval `json:"approvals"`
Events []CapabilityApprovalEvent `json:"events"`
Tombstones map[string]time.Time `json:"tombstones"`
Idempotency map[string]CapabilityApproval `json:"idempotency,omitempty"`
IdempotencyInputs map[string]approvalIdempotencyInput `json:"idempotency_inputs,omitempty"`
}
type approvalIdempotencyInput struct {
Revision uint64 `json:"revision"`
ManifestDigest string `json:"manifest_digest,omitempty"`
CapabilityIDs []string `json:"capability_ids,omitempty"`
Actor string `json:"actor"`
Reason string `json:"reason"`
}
var (
ErrApprovalRevisionConflict = errors.New("plugins: approval revision conflict")
ErrApprovalIdempotencyConflict = errors.New("plugins: approval idempotency conflict")
ErrApprovalInstallationTombstoned = errors.New("plugins: installation approval tombstoned")
)
type approvalLedger struct {
dir string
mu sync.Mutex
}
func newApprovalLedger(dir string) *approvalLedger {
return &approvalLedger{dir: dir}
}
func (l *approvalLedger) path() string {
return filepath.Join(l.dir, "approvals.json")
}
func (l *approvalLedger) load() (*approvalLedgerFile, error) {
data, err := os.ReadFile(l.path())
if err != nil {
if os.IsNotExist(err) {
return &approvalLedgerFile{
Approvals: map[string]CapabilityApproval{},
Tombstones: map[string]time.Time{},
Idempotency: map[string]CapabilityApproval{},
IdempotencyInputs: map[string]approvalIdempotencyInput{},
}, nil
}
return nil, err
}
var file approvalLedgerFile
if err := json.Unmarshal(data, &file); err != nil {
return nil, err
}
if file.Approvals == nil {
file.Approvals = map[string]CapabilityApproval{}
}
if file.Tombstones == nil {
file.Tombstones = map[string]time.Time{}
}
if file.Idempotency == nil {
file.Idempotency = map[string]CapabilityApproval{}
}
if file.IdempotencyInputs == nil {
file.IdempotencyInputs = map[string]approvalIdempotencyInput{}
}
return &file, nil
}
func (l *approvalLedger) save(file *approvalLedgerFile) error {
if err := os.MkdirAll(l.dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(file, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(l.dir, ".approvals-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer func() { _ = os.Remove(tmpPath) }()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, l.path()); err != nil {
return err
}
return syncApprovalLedgerDirectory(l.dir)
}
func approvalKey(installationID, workspaceID string) string {
return installationID + "\x00" + workspaceID
}
func approvalIdempotencyKey(eventType CapabilityApprovalEventType, auditID, installationID, workspaceID string) string {
return string(eventType) + "\x00" + auditID + "\x00" + installationID + "\x00" + workspaceID
}
func (l *approvalLedger) grant(installationID, workspaceID string, revision uint64, manifestDigest string, capabilityIDs []string, actor, reason, auditID string, at time.Time) (CapabilityApproval, error) {
if err := validateApprovalMutation(installationID, workspaceID, actor, reason, auditID); err != nil {
return CapabilityApproval{}, err
}
if revision == 0 {
return CapabilityApproval{}, ErrApprovalRevisionConflict
}
if strings.TrimSpace(auditID) == "" {
return CapabilityApproval{}, errors.New("plugins: approval audit id is required")
}
canonical, err := CanonicalCapabilityList(capabilityIDs)
if err != nil {
return CapabilityApproval{}, err
}
capabilityIDs = canonical
l.mu.Lock()
defer l.mu.Unlock()
file, err := l.load()
if err != nil {
return CapabilityApproval{}, err
}
key := approvalKey(installationID, workspaceID)
current := file.Approvals[key]
if _, tombstoned := file.Tombstones[installationID]; tombstoned {
return CapabilityApproval{}, ErrApprovalInstallationTombstoned
}
if replayed, found, err := approvalGrantReplay(file, installationID, workspaceID, revision, manifestDigest, capabilityIDs, actor, reason, auditID); found || err != nil {
return replayed, err
}
if !approvalRevisionFollows(current.Revision, revision) {
return CapabilityApproval{}, ErrApprovalRevisionConflict
}
approval := CapabilityApproval{
InstallationID: installationID,
WorkspaceID: workspaceID,
Revision: revision,
ManifestDigest: manifestDigest,
CapabilityIDs: append([]string{}, capabilityIDs...),
State: ApprovalStateActive,
HumanActor: actor,
HumanPolicyVersion: HumanPolicyVersionImmutable,
CreatedAt: at,
UpdatedAt: at,
}
if current.Revision != 0 {
approval.CreatedAt = current.CreatedAt
}
eventType := CapabilityApprovalEventGrant
if isStrictSubset(capabilityIDs, current.CapabilityIDs) {
eventType = CapabilityApprovalEventNarrow
}
file.Approvals[key] = approval
file.Idempotency[approvalIdempotencyKey(CapabilityApprovalEventGrant, auditID, installationID, workspaceID)] = approval
file.IdempotencyInputs[approvalIdempotencyKey(CapabilityApprovalEventGrant, auditID, installationID, workspaceID)] = approvalIdempotencyInput{
Revision: revision, ManifestDigest: manifestDigest, CapabilityIDs: append([]string{}, capabilityIDs...), Actor: actor, Reason: reason,
}
file.Events = append(file.Events, CapabilityApprovalEvent{
AuditID: auditID, InstallationID: installationID, WorkspaceID: workspaceID,
BeforeRevision: current.Revision, AfterRevision: revision,
BeforeDigest: current.ManifestDigest, AfterDigest: manifestDigest,
Actor: actor, Reason: reason, Type: eventType, ObservedAt: at,
})
return approval, l.save(file)
}
func isStrictSubset(candidate, current []string) bool {
return len(candidate) < len(current) && equalStrings(intersectStrings(current, candidate), candidate)
}
func approvalGrantReplay(file *approvalLedgerFile, installationID, workspaceID string, revision uint64, manifestDigest string, capabilityIDs []string, actor, reason, auditID string) (CapabilityApproval, bool, error) {
key := approvalIdempotencyKey(CapabilityApprovalEventGrant, auditID, installationID, workspaceID)
replayed, ok := file.Idempotency[key]
if !ok {
return CapabilityApproval{}, false, nil
}
input, inputOK := file.IdempotencyInputs[key]
if inputOK && input.Revision == revision && input.ManifestDigest == manifestDigest && equalStrings(input.CapabilityIDs, capabilityIDs) && input.Actor == actor && input.Reason == reason {
return replayed, true, nil
}
return CapabilityApproval{}, true, ErrApprovalIdempotencyConflict
}
func approvalRevisionFollows(current, next uint64) bool {
if current == 0 {
return next == 1
}
return next == current+1
}
func (l *approvalLedger) revoke(installationID, workspaceID string, actor, reason, auditID string, at time.Time) (CapabilityApproval, error) {
return l.revokeIfRevision(installationID, workspaceID, 0, actor, reason, auditID, at, true)
}
func (l *approvalLedger) revokeIfRevision(installationID, workspaceID string, expectedRevision uint64, actor, reason, auditID string, at time.Time, allowCurrent bool) (CapabilityApproval, error) {
if err := validateApprovalMutation(installationID, workspaceID, actor, reason, auditID); err != nil {
return CapabilityApproval{}, err
}
if strings.TrimSpace(auditID) == "" {
return CapabilityApproval{}, errors.New("plugins: approval audit id is required")
}
l.mu.Lock()
defer l.mu.Unlock()
file, err := l.load()
if err != nil {
return CapabilityApproval{}, err
}
key := approvalKey(installationID, workspaceID)
current, ok := file.Approvals[key]
if !ok {
return CapabilityApproval{}, fmt.Errorf("plugins: approval not found")
}
effectiveExpectedRevision := expectedRevision
if allowCurrent {
effectiveExpectedRevision = current.Revision
}
if auditID != "" {
idempotencyKey := approvalIdempotencyKey(CapabilityApprovalEventRevoke, auditID, installationID, workspaceID)
if replayed, ok := file.Idempotency[idempotencyKey]; ok {
input, inputOK := file.IdempotencyInputs[idempotencyKey]
// Current-revision callers intentionally pass allowCurrent=true and
// do not retain the pre-mutation revision. Once the first revoke
// advances the row, the audit identity is the only stable lookup for
// an exact retry. Explicit-revision callers remain bound to the
// persisted pre-revoke revision.
if inputOK && (allowCurrent || input.Revision == expectedRevision) && input.Actor == actor && input.Reason == reason {
return replayed, nil
}
return CapabilityApproval{}, ErrApprovalIdempotencyConflict
}
}
expectedRevision = effectiveExpectedRevision
if current.Revision != expectedRevision {
return CapabilityApproval{}, ErrApprovalRevisionConflict
}
next := current
next.Revision++
next.State = ApprovalStateRevoked
next.HumanActor = actor
next.UpdatedAt = at
file.Approvals[key] = next
if auditID != "" {
idempotencyKey := approvalIdempotencyKey(CapabilityApprovalEventRevoke, auditID, installationID, workspaceID)
file.Idempotency[idempotencyKey] = next
file.IdempotencyInputs[idempotencyKey] = approvalIdempotencyInput{Revision: expectedRevision, Actor: actor, Reason: reason}
}
file.Events = append(file.Events, CapabilityApprovalEvent{
AuditID: auditID, InstallationID: installationID, WorkspaceID: workspaceID,
BeforeRevision: current.Revision, AfterRevision: next.Revision,
BeforeDigest: current.ManifestDigest, AfterDigest: current.ManifestDigest,
Actor: actor, Reason: reason, Type: CapabilityApprovalEventRevoke, ObservedAt: at,
})
return next, l.save(file)
}
func (l *approvalLedger) tombstoneInstallation(installationID string, at time.Time) error {
if err := validateApprovalIdentifiers(installationID, ""); err != nil {
return err
}
l.mu.Lock()
defer l.mu.Unlock()
file, err := l.load()
if err != nil {
return err
}
if _, ok := file.Tombstones[installationID]; !ok {
file.Tombstones[installationID] = at
}
for key, approval := range file.Approvals {
if approval.InstallationID != installationID {
continue
}
if approval.TombstonedAt != nil {
continue
}
approval.Revision++
approval.State = ApprovalStateRevoked
approval.UpdatedAt = at
tombstonedAt := at
approval.TombstonedAt = &tombstonedAt
file.Approvals[key] = approval
file.Events = append(file.Events, CapabilityApprovalEvent{
AuditID: CanonicalApprovalDigest(installationID, approval.WorkspaceID, fmt.Sprint(approval.Revision), "uninstall"),
InstallationID: installationID, WorkspaceID: approval.WorkspaceID,
BeforeRevision: approval.Revision - 1, AfterRevision: approval.Revision,
BeforeDigest: approval.ManifestDigest, AfterDigest: approval.ManifestDigest,
Actor: "host", Reason: "installation uninstalled", Type: CapabilityApprovalEventRevoke, ObservedAt: at,
})
}
return l.save(file)
}
func (l *approvalLedger) reviewManifestChange(installationID, manifestDigest string, manifestCapabilities []string, at time.Time, force bool) error {
if err := validateApprovalIdentifiers(installationID, ""); err != nil {
return err
}
canonical, err := CanonicalCapabilityList(manifestCapabilities)
if err != nil {
return err
}
l.mu.Lock()
defer l.mu.Unlock()
file, err := l.load()
if err != nil {
return err
}
for key, approval := range file.Approvals {
if approval.InstallationID != installationID || (!force && approval.ManifestDigest == manifestDigest) || approval.TombstonedAt != nil {
continue
}
current := approval
approval.Revision++
approval.ManifestDigest = manifestDigest
approval.CapabilityIDs = intersectStrings(approval.CapabilityIDs, canonical)
approval.UpdatedAt = at
file.Approvals[key] = approval
file.Events = append(file.Events, CapabilityApprovalEvent{
AuditID: CanonicalApprovalDigest(installationID, approval.WorkspaceID, fmt.Sprint(approval.Revision), manifestDigest, "manifest-review"),
InstallationID: installationID, WorkspaceID: approval.WorkspaceID,
BeforeRevision: current.Revision, AfterRevision: approval.Revision,
BeforeDigest: current.ManifestDigest, AfterDigest: manifestDigest,
Actor: "host", Reason: "installation manifest changed", Type: CapabilityApprovalEventUpgradeReview, ObservedAt: at,
})
}
return l.save(file)
}
func intersectStrings(a, b []string) []string {
if len(a) == 0 || len(b) == 0 {
return nil
}
allowed := make(map[string]struct{}, len(b))
for _, v := range b {
allowed[v] = struct{}{}
}
out := make([]string, 0, len(a))
for _, v := range a {
if _, ok := allowed[v]; ok {
out = append(out, v)
}
}
return out
}
func (l *approvalLedger) get(installationID, workspaceID string) (CapabilityApproval, bool, error) {
if err := validateApprovalIdentifiers(installationID, workspaceID); err != nil {
return CapabilityApproval{}, false, err
}
l.mu.Lock()
defer l.mu.Unlock()
file, err := l.load()
if err != nil {
return CapabilityApproval{}, false, err
}
approval, ok := file.Approvals[approvalKey(installationID, workspaceID)]
return approval, ok, nil
}
func (l *approvalLedger) listByInstallation(installationID string) ([]CapabilityApproval, error) {
if err := validateApprovalIdentifiers(installationID, ""); err != nil {
return nil, err
}
l.mu.Lock()
defer l.mu.Unlock()
file, err := l.load()
if err != nil {
return nil, err
}
var out []CapabilityApproval
for _, approval := range file.Approvals {
if approval.InstallationID == installationID {
out = append(out, approval)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].WorkspaceID == out[j].WorkspaceID {
return out[i].Revision < out[j].Revision
}
return out[i].WorkspaceID < out[j].WorkspaceID
})
return out, nil
}
func validateApprovalIdentifiers(installationID, workspaceID string) error {
if !isBoundedApprovalIdentifier(installationID) || (workspaceID != "" && !isBoundedApprovalIdentifier(workspaceID)) {
return ErrApprovalInvalidIdentifier
}
return nil
}
func validateApprovalAuditMetadata(actor, reason, auditID string) error {
for _, value := range []string{actor, reason, auditID} {
if value == "" || len(value) > maxApprovalIDLength || strings.ContainsRune(value, '\x00') {
return errors.New("plugins: approval audit metadata is malformed")
}
}
return nil
}
func validateApprovalMutation(installationID, workspaceID, actor, reason, auditID string) error {
if err := validateApprovalIdentifiers(installationID, workspaceID); err != nil {
return err
}
return validateApprovalAuditMetadata(actor, reason, auditID)
}
@@ -0,0 +1,17 @@
//go:build !windows
package plugins
import "os"
// syncApprovalLedgerDirectory flushes the rename metadata on platforms that
// expose directory handles to fsync. The ledger file is already synced before
// the rename.
func syncApprovalLedgerDirectory(path string) error {
dir, err := os.Open(path)
if err != nil {
return err
}
defer func() { _ = dir.Close() }()
return dir.Sync()
}
@@ -0,0 +1,11 @@
//go:build windows
package plugins
// Windows does not provide a usable directory fsync handle through Go's os
// package. The ledger file is flushed before rename, so the replacement is
// complete without attempting the read-only directory Sync that fails on
// Windows.
func syncApprovalLedgerDirectory(string) error {
return nil
}
@@ -0,0 +1,511 @@
package plugins
import (
"errors"
"testing"
"time"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/store"
)
func TestApprovalLedgerGrantRevokeAndTombstone(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
grantAt := time.Date(2026, time.August, 31, 12, 1, 0, 0, time.UTC)
approval, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", grantAt)
if err != nil {
t.Fatalf("grant: %v", err)
}
if approval.State != ApprovalStateActive || approval.Revision != 1 {
t.Fatalf("grant approval = %#v", approval)
}
fetched, ok, err := ledger.get("inst-1", "ws-1")
if err != nil || !ok {
t.Fatalf("get: ok=%v err=%v", ok, err)
}
if fetched.ManifestDigest != "digest-a" || len(fetched.CapabilityIDs) != 1 {
t.Fatalf("fetched = %#v", fetched)
}
revoked, err := ledger.revoke("inst-1", "ws-1", "human", "revoke", "audit-2", grantAt.Add(time.Minute))
if err != nil {
t.Fatalf("revoke: %v", err)
}
if revoked.State != ApprovalStateRevoked || revoked.Revision != 2 {
t.Fatalf("revoked = %#v", revoked)
}
if err := ledger.tombstoneInstallation("inst-1", grantAt.Add(2*time.Minute)); err != nil {
t.Fatalf("tombstone: %v", err)
}
after, ok, err := ledger.get("inst-1", "ws-1")
if err != nil || !ok {
t.Fatalf("get after tombstone: ok=%v err=%v", ok, err)
}
if after.Revision != 3 || after.State != ApprovalStateRevoked {
t.Fatalf("after tombstone = %#v", after)
}
}
func TestAuthorizePluginCapabilityStableDenyReasons(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
decision := svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, "req", "method")
if decision.Allowed {
t.Fatal("decision unexpectedly allowed without approval")
}
if decision.Reason != ApprovalDenyMissingApproval {
t.Fatalf("reason = %q, want missing approval", decision.Reason)
}
}
func TestApprovalLedgerGrantRejectsRevisionRegression(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", time.Now().UTC()); err != nil {
t.Fatalf("first grant: %v", err)
}
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-b", []string{"host.v2.read:tasks"}, "human", "grant", "audit-2", time.Now().UTC()); err == nil {
t.Fatal("grant accepted a non-incrementing revision")
}
}
func TestApprovalLedgerGrantRecordsHumanNarrowing(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
at := time.Now().UTC()
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:messages", "host.v2.read:tasks"}, "human", "grant", "audit-1", at); err != nil {
t.Fatalf("initial grant: %v", err)
}
if _, err := ledger.grant("inst-1", "ws-1", 2, "digest-a", []string{"host.v2.read:tasks"}, "human", "remove messages access", "audit-2", at.Add(time.Second)); err != nil {
t.Fatalf("narrow approval: %v", err)
}
file, err := ledger.load()
if err != nil {
t.Fatalf("load: %v", err)
}
if got := file.Events[1].Type; got != CapabilityApprovalEventNarrow {
t.Fatalf("narrowing event type = %q, want %q", got, CapabilityApprovalEventNarrow)
}
}
func TestApprovalLedgerGrantRejectsZeroRevision(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
if _, err := ledger.grant("inst-1", "ws-1", 0, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", time.Now().UTC()); err == nil {
t.Fatal("grant accepted revision zero")
}
if _, ok, err := ledger.get("inst-1", "ws-1"); err != nil || ok {
t.Fatalf("grant persisted revision zero approval: ok=%v err=%v", ok, err)
}
}
func TestApprovalLedgerGrantRejectsNonInitialRevision(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
if _, err := ledger.grant("inst-1", "ws-1", 2, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", time.Now().UTC()); err == nil {
t.Fatal("grant accepted non-initial revision")
}
}
func TestApprovalLedgerTombstonePersistsAcrossReload(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", time.Now().UTC()); err != nil {
t.Fatalf("grant: %v", err)
}
if err := ledger.tombstoneInstallation("inst-1", time.Now().UTC()); err != nil {
t.Fatalf("tombstone: %v", err)
}
reloaded := newApprovalLedger(dir)
approval, ok, err := reloaded.get("inst-1", "ws-1")
if err != nil || !ok {
t.Fatalf("reloaded get: ok=%v err=%v", ok, err)
}
if approval.State != ApprovalStateRevoked || approval.Revision != 2 {
t.Fatalf("approval after reload = %#v", approval)
}
}
func TestAuthorizePluginCapabilityAllowsExactCurrentRevision(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant: %v", err)
}
decision := svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, "req", "method")
if !decision.Allowed {
t.Fatalf("decision = %#v, want allowed for exact current revision", decision)
}
if decision.Receipt.Result != "allowed" {
t.Fatalf("receipt result = %q, want allowed", decision.Receipt.Result)
}
}
func TestAuthorizePluginCapabilityDeniesStaleRevision(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("initial grant: %v", err)
}
if _, err := svc.approvalGrant("inst-1", "ws-1", 2, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-2"); err != nil {
t.Fatalf("second grant: %v", err)
}
decision := svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, "req", "method")
if decision.Allowed {
t.Fatalf("decision = %#v, want stale revision denied", decision)
}
if decision.Reason != ApprovalDenyStaleRevision {
t.Fatalf("reason = %q, want stale_capability_revision", decision.Reason)
}
}
func TestAuthorizePluginCapabilityDeniesHumanReservedCapability(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"merge"}, "human", "grant", "audit-1"); err == nil {
t.Fatal("approvalGrant persisted a Human-reserved capability")
}
decision := svc.authorizePluginCapability("inst-1", "ws-1", "merge", 1, "req", "method")
if decision.Allowed {
t.Fatalf("decision = %#v, want human-reserved deny", decision)
}
if decision.Reason != ApprovalDenyHumanReserved {
t.Fatalf("reason = %q, want human_reserved", decision.Reason)
}
}
func TestAuthorizePluginCapabilityDeniesMalformedRequest(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
cases := []struct {
name string
installationID string
workspaceID string
requestDigest string
methodDigest string
}{
{"missing installation", "", "ws-1", "req", "method"},
{"missing workspace", "inst-1", "", "req", "method"},
{"missing request digest", "inst-1", "ws-1", "", "method"},
{"missing method digest", "inst-1", "ws-1", "req", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
decision := svc.authorizePluginCapability(tc.installationID, tc.workspaceID, "host.v2.read:tasks", 1, tc.requestDigest, tc.methodDigest)
if decision.Allowed {
t.Fatalf("decision unexpectedly allowed: %#v", decision)
}
if decision.Reason != ApprovalDenyMalformedRequest {
t.Fatalf("reason = %q, want malformed_request", decision.Reason)
}
})
}
}
func TestAuthorizePluginCapabilityDeniesUnsupportedCapabilityID(t *testing.T) {
dir := t.TempDir()
svc := &Service{}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant: %v", err)
}
for _, capabilityID := range []string{"", "host.v2.read:*", "host.v2.read:tas?s", " host.v2.read:tasks"} {
decision := svc.authorizePluginCapability("inst-1", "ws-1", capabilityID, 1, "req", "method")
if decision.Allowed {
t.Fatalf("capability %q unexpectedly allowed", capabilityID)
}
if decision.Reason != ApprovalDenyUnsupportedCapability {
t.Fatalf("capability %q reason = %q, want unsupported_capability", capabilityID, decision.Reason)
}
}
}
func TestApprovalGrantRetryIsIdempotent(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
at := time.Now().UTC()
first, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", at)
if err != nil {
t.Fatalf("first grant: %v", err)
}
second, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", at.Add(time.Second))
if err != nil {
t.Fatalf("idempotent retry: %v", err)
}
if second.Revision != first.Revision || !second.UpdatedAt.Equal(first.UpdatedAt) {
t.Fatalf("retry changed approval: first=%#v second=%#v", first, second)
}
file, err := ledger.load()
if err != nil {
t.Fatalf("load: %v", err)
}
if len(file.Events) != 1 {
t.Fatalf("event count = %d, want one idempotent event", len(file.Events))
}
}
func TestApprovalGrantRetryRejectsChangedAuditPayload(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
at := time.Now().UTC()
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", at); err != nil {
t.Fatalf("first grant: %v", err)
}
tests := []struct {
name string
actor string
reason string
}{
{name: "actor", actor: "other-human", reason: "grant"},
{name: "reason", actor: "human", reason: "different reason"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, tc.actor, tc.reason, "audit-1", at.Add(time.Second)); !errors.Is(err, ErrApprovalIdempotencyConflict) {
t.Fatalf("changed grant payload error = %v, want idempotency conflict", err)
}
})
}
}
func TestApprovalRevokeRetryRejectsChangedAuditPayload(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
at := time.Now().UTC()
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "grant-1", at); err != nil {
t.Fatalf("grant: %v", err)
}
if _, err := ledger.revokeIfRevision("inst-1", "ws-1", 1, "human", "revoke", "revoke-1", at.Add(time.Second), false); err != nil {
t.Fatalf("first revoke: %v", err)
}
tests := []struct {
name string
expectedRevision uint64
actor string
reason string
}{
{name: "revision", expectedRevision: 99, actor: "human", reason: "revoke"},
{name: "actor", expectedRevision: 1, actor: "other-human", reason: "revoke"},
{name: "reason", expectedRevision: 1, actor: "human", reason: "different reason"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if _, err := ledger.revokeIfRevision("inst-1", "ws-1", tc.expectedRevision, tc.actor, tc.reason, "revoke-1", at.Add(2*time.Second), false); !errors.Is(err, ErrApprovalIdempotencyConflict) {
t.Fatalf("changed revoke payload error = %v, want idempotency conflict", err)
}
})
}
}
func TestApprovalRevokeRetryReplaysExactPayloadAfterRevisionAdvances(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
at := time.Now().UTC()
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "grant-1", at); err != nil {
t.Fatalf("grant: %v", err)
}
first, err := ledger.revokeIfRevision("inst-1", "ws-1", 1, "human", "revoke", "revoke-1", at.Add(time.Second), false)
if err != nil {
t.Fatalf("first revoke: %v", err)
}
file, err := ledger.load()
if err != nil {
t.Fatalf("load after revoke: %v", err)
}
current := file.Approvals[approvalKey("inst-1", "ws-1")]
if current.Revision != first.Revision {
t.Fatalf("loaded approval revision = %d, want %d", current.Revision, first.Revision)
}
replayed, err := ledger.revokeIfRevision("inst-1", "ws-1", 1, "human", "revoke", "revoke-1", at.Add(2*time.Second), false)
if err != nil {
t.Fatalf("exact replay revoke: %v", err)
}
if replayed.Revision != first.Revision || replayed.State != first.State || !replayed.UpdatedAt.Equal(first.UpdatedAt) {
t.Fatalf("retry did not replay original revoke: first=%#v replayed=%#v", first, replayed)
}
file, err = ledger.load()
if err != nil {
t.Fatalf("reload after replay: %v", err)
}
if len(file.Events) != 2 {
t.Fatalf("event count = %d, want grant and first revoke only", len(file.Events))
}
}
func TestApprovalGrantRetryReplaysAfterLaterRevision(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
at := time.Now().UTC()
first, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", at)
if err != nil {
t.Fatalf("first grant: %v", err)
}
if _, err := ledger.grant("inst-1", "ws-1", 2, "digest-b", []string{"host.v2.read:tasks"}, "human", "upgrade", "audit-2", at.Add(time.Second)); err != nil {
t.Fatalf("second grant: %v", err)
}
replayed, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", at.Add(2*time.Second))
if err != nil {
t.Fatalf("retry original grant after later revision: %v", err)
}
if replayed.Revision != first.Revision || replayed.ManifestDigest != first.ManifestDigest || !replayed.UpdatedAt.Equal(first.UpdatedAt) {
t.Fatalf("retry did not replay original result: first=%#v replayed=%#v", first, replayed)
}
file, err := ledger.load()
if err != nil {
t.Fatalf("load: %v", err)
}
if len(file.Events) != 2 {
t.Fatalf("event count = %d, want original two events only", len(file.Events))
}
}
func TestApprovalTombstoneAppendsWorkspaceEventAndMarksRow(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
at := time.Now().UTC()
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", at); err != nil {
t.Fatalf("grant: %v", err)
}
if err := ledger.tombstoneInstallation("inst-1", at.Add(time.Second)); err != nil {
t.Fatalf("tombstone: %v", err)
}
row, ok, err := ledger.get("inst-1", "ws-1")
if err != nil || !ok {
t.Fatalf("get: ok=%v err=%v", ok, err)
}
if row.TombstonedAt == nil {
t.Fatal("tombstone did not mark current approval")
}
file, err := ledger.load()
if err != nil {
t.Fatalf("load: %v", err)
}
if len(file.Events) != 2 || file.Events[1].Type != CapabilityApprovalEventRevoke || file.Events[1].AuditID == "" {
t.Fatalf("tombstone events = %#v", file.Events)
}
}
func TestApprovalTombstoneRetryDoesNotAdvanceRevisionOrDuplicateEvents(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
at := time.Now().UTC()
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", at); err != nil {
t.Fatalf("grant: %v", err)
}
if err := ledger.tombstoneInstallation("inst-1", at.Add(time.Second)); err != nil {
t.Fatalf("first tombstone: %v", err)
}
first, ok, err := ledger.get("inst-1", "ws-1")
if err != nil || !ok {
t.Fatalf("get after first tombstone: ok=%v err=%v", ok, err)
}
if err := ledger.tombstoneInstallation("inst-1", at.Add(2*time.Second)); err != nil {
t.Fatalf("second tombstone: %v", err)
}
second, ok, err := ledger.get("inst-1", "ws-1")
if err != nil || !ok {
t.Fatalf("get after second tombstone: ok=%v err=%v", ok, err)
}
if second.Revision != first.Revision || !second.UpdatedAt.Equal(first.UpdatedAt) {
t.Fatalf("tombstone retry advanced row: first=%#v second=%#v", first, second)
}
file, err := ledger.load()
if err != nil {
t.Fatalf("load: %v", err)
}
if len(file.Events) != 2 {
t.Fatalf("event count = %d, want grant plus one tombstone revoke", len(file.Events))
}
}
func TestApprovalLedgerRejectsGrantForTombstonedInstallation(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", time.Now().UTC()); err != nil {
t.Fatalf("grant: %v", err)
}
if err := ledger.tombstoneInstallation("inst-1", time.Now().UTC()); err != nil {
t.Fatalf("tombstone: %v", err)
}
if _, err := ledger.grant("inst-1", "ws-1", 3, "digest-b", []string{"host.v2.read:tasks"}, "human", "regrant", "audit-2", time.Now().UTC()); err == nil {
t.Fatal("grant accepted tombstoned installation")
}
}
func TestApprovalLedgerRejectsMissingAuditID(t *testing.T) {
dir := t.TempDir()
ledger := newApprovalLedger(dir)
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "", time.Now().UTC()); err == nil {
t.Fatal("grant accepted empty audit id")
}
}
func TestAuthorizePluginCapabilityRequiresCurrentInstalledManifest(t *testing.T) {
dir := t.TempDir()
svc := &Service{registry: NewRegistry()}
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
svc.registry.Add(&store.Record{
Manifest: manifest.Manifest{ID: "plugin-a", Capabilities: manifest.Capabilities{APIRead: []string{"tasks"}}},
InstallationID: "inst-1",
})
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, ManifestCapabilityDigest(svc.registry.List()[0].Manifest), []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant: %v", err)
}
decision := svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.write:tasks", 1, "req", "method")
if decision.Allowed || decision.Reason != ApprovalDenyUndeclaredCapability {
t.Fatalf("decision = %#v, want manifest intersection denial", decision)
}
}
func TestApprovalLedgerRejectsNULIdentifiers(t *testing.T) {
ledger := newApprovalLedger(t.TempDir())
if _, err := ledger.grant("inst\x00-1", "ws-1", 1, "digest", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", time.Now().UTC()); !errors.Is(err, ErrApprovalInvalidIdentifier) {
t.Fatalf("grant error = %v, want invalid identifier", err)
}
}
@@ -0,0 +1,15 @@
//go:build windows
package plugins
import (
"testing"
"time"
)
func TestApprovalLedgerGrantWorksOnWindows(t *testing.T) {
ledger := newApprovalLedger(t.TempDir())
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, "human", "grant", "audit-1", time.Now().UTC()); err != nil {
t.Fatalf("grant on Windows: %v", err)
}
}
@@ -0,0 +1,236 @@
package plugins
import (
"strings"
"testing"
"time"
)
func TestCanonicalCapabilityListSortsDeduplicatesAndTrims(t *testing.T) {
got, err := CanonicalCapabilityList([]string{" host.v2.write:tasks ", "host.v2.read:tasks", "host.v2.write:tasks"})
if err != nil {
t.Fatalf("CanonicalCapabilityList() unexpected error: %v", err)
}
want := []string{"host.v2.read:tasks", "host.v2.write:tasks"}
if len(got) != len(want) {
t.Fatalf("len = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestCanonicalCapabilityListRejectsWildcards(t *testing.T) {
if _, err := CanonicalCapabilityList([]string{"host.v2.read:*"}); err == nil {
t.Fatal("CanonicalCapabilityList() accepted a wildcard capability")
}
}
func TestCanonicalCapabilityListRequiresExactHostV2Capabilities(t *testing.T) {
for _, capability := range []string{
"api_read:tasks",
"api_write:tasks",
"host.v2.read",
"host.v2.read:tasks:extra",
"host.v2.admin:tasks",
} {
if _, err := CanonicalCapabilityList([]string{capability}); err == nil {
t.Fatalf("CanonicalCapabilityList(%q) accepted a non-exact H6 capability", capability)
}
}
got, err := CanonicalCapabilityList([]string{"host.v2.write:tasks", "host.v2.read:tasks"})
if err != nil {
t.Fatalf("CanonicalCapabilityList() rejected exact H6 capabilities: %v", err)
}
want := []string{"host.v2.read:tasks", "host.v2.write:tasks"}
if !equalStrings(got, want) {
t.Fatalf("capabilities = %#v, want %#v", got, want)
}
}
func TestCanonicalCapabilityListRejectsNamespacedHumanReservedCapabilities(t *testing.T) {
for _, capability := range []string{
"api_write:merge",
"host.v2.write:merge",
"host.v2.write:deploy",
"host.v2.write:release",
"host.v2.write:rewrite_history",
"host.v2.write:cross_workspace",
"host.v2.write:secret_scope_expand",
} {
if _, err := CanonicalCapabilityList([]string{capability}); err == nil {
t.Fatalf("CanonicalCapabilityList(%q) accepted a Human-reserved capability", capability)
}
}
}
func TestAuthorizePluginCapabilityDoesNotTreatV1DeclarationsAsH6Authority(t *testing.T) {
svc := &Service{}
if err := svc.SetPluginsDir(t.TempDir()); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalGrant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.write:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant: %v", err)
}
for _, capabilityID := range []string{"api_read:tasks", "api_write:tasks"} {
decision := svc.authorizePluginCapability("inst-1", "ws-1", capabilityID, 1, "request", "method")
if decision.Allowed || decision.Reason != ApprovalDenyUnsupportedCapability {
t.Fatalf("legacy %q decision = %#v, want unsupported denial", capabilityID, decision)
}
}
for _, capabilityID := range []string{"api_write:merge", "host.v2.write:merge"} {
decision := svc.authorizePluginCapability("inst-1", "ws-1", capabilityID, 1, "request", "method")
if decision.Allowed || decision.Reason != ApprovalDenyHumanReserved {
t.Fatalf("reserved %q decision = %#v, want Human-policy denial", capabilityID, decision)
}
}
}
func TestCanonicalCapabilityListBoundsCapabilityInputs(t *testing.T) {
tooLong := "host.v2.read:" + strings.Repeat("a", 1024)
if _, err := CanonicalCapabilityList([]string{tooLong}); err == nil {
t.Fatal("CanonicalCapabilityList() accepted an oversized capability")
}
capabilities := make([]string, 1025)
for i := range capabilities {
capabilities[i] = "host.v2.read:tasks"
}
if _, err := CanonicalCapabilityList(capabilities); err == nil {
t.Fatal("CanonicalCapabilityList() accepted too many capability inputs")
}
}
func TestApprovalLedgerRejectsOversizedOrMalformedAuditMetadata(t *testing.T) {
ledger := newApprovalLedger(t.TempDir())
for _, tc := range []struct {
name string
actor string
reason string
audit string
}{
{name: "oversized actor", actor: strings.Repeat("a", 1025), reason: "grant", audit: "audit-1"},
{name: "oversized reason", actor: "human", reason: strings.Repeat("a", 1025), audit: "audit-1"},
{name: "nul audit id", actor: "human", reason: "grant", audit: "audit\x00secret"},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := ledger.grant("inst-1", "ws-1", 1, "digest-a", []string{"host.v2.read:tasks"}, tc.actor, tc.reason, tc.audit, time.Now().UTC()); err == nil {
t.Fatal("grant accepted unsafe audit metadata")
}
})
}
}
func TestCanonicalApprovalDigestIsStable(t *testing.T) {
got1 := CanonicalApprovalDigest(" installation ", "workspace", "7")
got2 := CanonicalApprovalDigest("installation", " workspace ", "7")
if got1 != got2 {
t.Fatalf("CanonicalApprovalDigest() is not stable: %q != %q", got1, got2)
}
}
func TestApprovalReceiptCarriesSafeMetadata(t *testing.T) {
when := time.Date(2026, time.August, 31, 12, 0, 0, 0, time.UTC)
receipt := ApprovalReceipt{
InstallationID: "inst-1",
WorkspaceID: "ws-1",
Revision: 3,
CapabilityID: "host.v2.read:tasks",
RequestDigest: "req",
MethodDigest: "method",
AuditID: "audit-1",
Result: "allowed",
ObservedAt: when,
}
if receipt.InstallationID != "inst-1" || receipt.WorkspaceID != "ws-1" || receipt.Revision != 3 {
t.Fatalf("receipt = %#v, want safe identity fields preserved", receipt)
}
if receipt.ObservedAt != when {
t.Fatalf("ObservedAt = %v, want %v", receipt.ObservedAt, when)
}
}
func TestAuthorizePluginCapabilityCopiesAuditIDToDeniedDecision(t *testing.T) {
svc := &Service{}
if err := svc.SetPluginsDir(t.TempDir()); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
decision := svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, "req", "method")
if decision.AuditID == "" || decision.AuditID != decision.Receipt.AuditID {
t.Fatalf("denied decision audit id = %q, receipt audit id = %q", decision.AuditID, decision.Receipt.AuditID)
}
}
func TestAuthorizePluginCapabilityBoundsAndDigestsReceiptInputs(t *testing.T) {
svc := &Service{}
if err := svc.SetPluginsDir(t.TempDir()); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
secret := "token=super-secret-value"
decision := svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, secret, "method=secret")
if decision.Receipt.RequestDigest == secret || decision.Receipt.MethodDigest == "method=secret" {
t.Fatalf("receipt leaked request input: %#v", decision.Receipt)
}
if len(decision.Receipt.RequestDigest) != 64 || len(decision.Receipt.MethodDigest) != 64 {
t.Fatalf("receipt digest lengths = %d, %d; want SHA-256 hex", len(decision.Receipt.RequestDigest), len(decision.Receipt.MethodDigest))
}
tooLong := strings.Repeat("a", 1025)
for _, tc := range []struct {
name string
call func() ApprovalDecision
}{
{"installation", func() ApprovalDecision {
return svc.authorizePluginCapability(tooLong, "ws-1", "host.v2.read:tasks", 1, "request", "method")
}},
{"workspace", func() ApprovalDecision {
return svc.authorizePluginCapability("inst-1", tooLong, "host.v2.read:tasks", 1, "request", "method")
}},
{"request", func() ApprovalDecision {
return svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, strings.Repeat("a", 4097), "method")
}},
{"method", func() ApprovalDecision {
return svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, "request", strings.Repeat("a", 4097))
}},
{"nul request", func() ApprovalDecision {
return svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, "request\x00secret", "method")
}},
{"nul method", func() ApprovalDecision {
return svc.authorizePluginCapability("inst-1", "ws-1", "host.v2.read:tasks", 1, "request", "method\x00secret")
}},
} {
t.Run(tc.name, func(t *testing.T) {
if decision := tc.call(); decision.Reason != ApprovalDenyMalformedRequest {
t.Fatalf("reason = %q, want malformed_request", decision.Reason)
}
})
}
malformed := svc.authorizePluginCapability(tooLong, "ws-1", "host.v2.read:tasks", 1, "request", "method")
if malformed.Receipt.InstallationID != "" {
t.Fatalf("receipt leaked malformed installation input: %#v", malformed.Receipt)
}
}
func TestAuthorizePluginCapabilityBoundsReservedCapabilityReceipt(t *testing.T) {
svc := &Service{}
if err := svc.SetPluginsDir(t.TempDir()); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
tooLong := strings.Repeat("a", maxCapabilityIDLength) + ":merge"
for _, capabilityID := range []string{tooLong, "host.v2.read\x00:merge"} {
t.Run(capabilityID, func(t *testing.T) {
decision := svc.authorizePluginCapability("inst-1", "ws-1", capabilityID, 1, "request", "method")
if decision.Receipt.CapabilityID != "" {
t.Fatalf("receipt capability id = %q, want empty for unsafe input", decision.Receipt.CapabilityID)
}
})
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ func ProvideWithStoreErrors(cfg *config.Config, dbPool *db.Pool, secrets SecretV
registry := NewRegistry()
if err := registry.Load(pluginStore); err != nil {
warnProvider(log, "Plugins registry load failed; starting with an empty registry", err)
warnProvider(log, "Plugins registry migration reported an error; continuing with loaded records", err)
}
svc := NewService(pluginStore, registry, eventBus, log)
+20 -2
View File
@@ -1,15 +1,23 @@
package plugins
import (
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/plugins/store"
)
// Registry is an in-memory, mutex-guarded index of installed plugins, keyed
// by plugin id. It is loaded from the filesystem store at startup (Load)
// and kept in sync as Service mutates installations, so read paths (List,
// Get) never hit disk.
//
// Get and List return copies of the stored *store.Record so callers cannot
// mutate registry state by holding onto a returned pointer; all writes go
// through Add / Remove / SetStatus / SetRuntimeState / SetAutoUpdate /
// SetRestartCount.
@@ -33,14 +41,24 @@ func (r *Registry) Load(s store.Store) error {
}
byID := make(map[string]*store.Record, len(records))
var migrationErrs []error
for _, rec := range records {
if rec.InstallationID == "" {
migrated := cloneRecord(rec)
migrated.InstallationID = uuid.NewString()
if err := s.Save(migrated); err != nil {
migrationErrs = append(migrationErrs, fmt.Errorf("migrate plugin installation id for %s: %w", rec.ID, err))
} else {
rec = migrated
}
}
byID[rec.ID] = cloneRecord(rec)
}
r.mu.Lock()
defer r.mu.Unlock()
r.byID = byID
return nil
r.mu.Unlock()
return errors.Join(migrationErrs...)
}
// Get returns a copy of the record for id, and whether it was found.
@@ -1,12 +1,35 @@
package plugins
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/store"
)
// migrationSaveErrorStore exposes both legacy and already-migrated records,
// then fails the migration write. Registry.Load must still publish the full
// List result so boot scans cannot treat valid records as sideloads.
type migrationSaveErrorStore struct {
store.Store
records []*store.Record
saves int
}
func (s *migrationSaveErrorStore) List() ([]*store.Record, error) {
return s.records, nil
}
func (s *migrationSaveErrorStore) Save(*store.Record) error {
s.saves++
return errors.New("simulated migration save failure")
}
// testManifest returns a minimal valid runtime-managed manifest for tests
// that only need a store.Record, not a full installed package (see
// service_test.go's testPackage for that).
@@ -44,6 +67,140 @@ func TestRegistryLoadPopulatesFromStore(t *testing.T) {
}
}
func TestRegistryLoadMigratesMissingInstallationIDOnce(t *testing.T) {
dir := t.TempDir()
fsStore := store.NewFSStore(dir)
if err := fsStore.Save(&store.Record{Manifest: *testManifest("kandev-plugin-slack"), Status: store.StatusRegistered}); err != nil {
t.Fatalf("Save: %v", err)
}
reg := NewRegistry()
if err := reg.Load(fsStore); err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
first, ok := reg.Get("kandev-plugin-slack")
if !ok {
t.Fatal("Get() expected record to be present after Load()")
}
if first.InstallationID == "" {
t.Fatal("Load() did not mint an installation id for a legacy record")
}
reloaded, err := fsStore.Get("kandev-plugin-slack")
if err != nil {
t.Fatalf("reloaded record: %v", err)
}
if reloaded.InstallationID != first.InstallationID {
t.Fatalf("installation id changed across migration/save: first=%q reloaded=%q", first.InstallationID, reloaded.InstallationID)
}
reg2 := NewRegistry()
if err := reg2.Load(fsStore); err != nil {
t.Fatalf("second Load() unexpected error: %v", err)
}
second, ok := reg2.Get("kandev-plugin-slack")
if !ok {
t.Fatal("Get() expected record to be present after second Load()")
}
if second.InstallationID != first.InstallationID {
t.Fatalf("installation id was regenerated on replay: first=%q second=%q", first.InstallationID, second.InstallationID)
}
}
func TestRegistryLoadRetainsRecordsWhenMigrationSaveFails(t *testing.T) {
legacy := &store.Record{Manifest: *testManifest("kandev-plugin-legacy"), Status: store.StatusDisabled}
migrated := &store.Record{
Manifest: *testManifest("kandev-plugin-migrated"),
Status: store.StatusActive,
InstallationID: "stable-installation",
}
registry := NewRegistry()
backend := &migrationSaveErrorStore{records: []*store.Record{legacy, migrated}}
err := registry.Load(backend)
if err == nil {
t.Fatal("Load() expected migration error")
}
for _, want := range []*store.Record{legacy, migrated} {
got, ok := registry.Get(want.ID)
if !ok {
t.Fatalf("Load() dropped %q after migration failure", want.ID)
}
if got.Status != want.Status {
t.Fatalf("Load() status for %q = %q, want %q", want.ID, got.Status, want.Status)
}
}
if got, _ := registry.Get(legacy.ID); got.InstallationID != "" {
t.Fatalf("Load() exposed an unpersisted installation id %q", got.InstallationID)
}
pluginsDir := t.TempDir()
manifestPath := filepath.Join(pluginsDir, legacy.ID, "1.0.0", manifestFileName)
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
t.Fatalf("MkdirAll() = %v", err)
}
if err := os.WriteFile(manifestPath, []byte("id: kandev-plugin-legacy\napi_version: 1\nversion: 1.0.0\ndisplay_name: Legacy\nruntime:\n type: binary\n executables:\n linux-amd64: server/plugin\n"), 0o644); err != nil {
t.Fatalf("WriteFile() = %v", err)
}
svc := &Service{store: backend, registry: registry, log: testLogger(t)}
if err := svc.SetPluginsDir(pluginsDir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.approvalLedger().grant("stable-installation", "workspace-1", 1, "digest", nil, "human", "grant", "audit-1", time.Now().UTC()); err != nil {
t.Fatalf("seed approval: %v", err)
}
result := svc.bootScan(context.Background())
if len(result.Added) != 0 {
t.Fatalf("boot scan registered an existing record as a sideload: %#v", result.Added)
}
if backend.saves != 1 {
t.Fatalf("boot scan performed %d store saves, want only the failed migration save", backend.saves)
}
if _, ok, err := svc.approvalCurrent("stable-installation", "workspace-1"); err != nil || !ok {
t.Fatalf("boot scan lost an unrelated approval: ok=%v err=%v", ok, err)
}
}
func TestServiceUninstallLegacyRecordAfterInstallationIDMigrationFails(t *testing.T) {
pluginsDir := t.TempDir()
fsStore := store.NewFSStore(pluginsDir)
legacy := &store.Record{
Manifest: *testManifest("kandev-plugin-legacy"),
Status: store.StatusDisabled,
InstallPath: filepath.Join(pluginsDir, "kandev-plugin-legacy", "1.0.0"),
}
if err := os.MkdirAll(legacy.InstallPath, 0o755); err != nil {
t.Fatalf("MkdirAll() = %v", err)
}
if err := fsStore.Save(legacy); err != nil {
t.Fatalf("Save() = %v", err)
}
registry := NewRegistry()
backend := &migrationSaveErrorStore{Store: fsStore, records: []*store.Record{legacy}}
if err := registry.Load(backend); err == nil {
t.Fatal("Load() expected migration error")
}
svc := NewService(backend, registry, nil, testLogger(t))
if err := svc.SetPluginsDir(pluginsDir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
t.Cleanup(func() { _ = svc.Close() })
if err := svc.Uninstall(t.Context(), legacy.ID); err != nil {
t.Fatalf("Uninstall() legacy record: %v", err)
}
if _, err := fsStore.Get(legacy.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("store.Get() after Uninstall() = %v, want store.ErrNotFound", err)
}
if _, ok := registry.Get(legacy.ID); ok {
t.Fatal("registry retained legacy record after Uninstall()")
}
}
func TestRegistryGetMissingReturnsNotOK(t *testing.T) {
reg := NewRegistry()
if _, ok := reg.Get("missing"); ok {
+2
View File
@@ -96,6 +96,7 @@ type Service struct {
pluginsDir string
store store.Store
approvals *approvalLedger
registry *Registry
state *state.Store
userState *state.UserStore
@@ -699,6 +700,7 @@ func (s *Service) SetPluginsDir(dir string) error {
// Keep package installation rooted correctly even when durable conversation
// state initialization fails and the caller continues in degraded mode.
s.pluginsDir = dir
s.approvals = newApprovalLedger(dir)
hostDir := filepath.Join(dir, ".host")
conversationTokens, err := loadOrCreateConversationTokenManager(
filepath.Join(hostDir, "conversation-token.key"),
@@ -11,6 +11,7 @@ import (
"os"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/plugins/manifest"
@@ -117,12 +118,33 @@ func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, erro
// not a manifest fact) must be carried forward or an auto-update would
// silently reset the very toggle that triggered it.
if hadOldRec {
rec.InstallationID = oldRec.InstallationID
rec.AutoUpdate = oldRec.AutoUpdate
}
if rec.InstallationID == "" {
rec.InstallationID = uuid.NewString()
}
if err := s.store.Save(rec); err != nil {
s.rollbackFailedInstall(result.InstallPath, oldRec, hadOldRec && wasRunning)
return nil, fmt.Errorf("plugins: persist installed record: %w", err)
}
if err := s.reviewInstalledApprovals(rec); err != nil {
if hadOldRec {
if restoreErr := s.store.Save(oldRec); restoreErr != nil {
s.log.Warn("plugins: failed to restore old plugin record after approval review error",
zap.String("plugin_id", rec.ID), zap.Error(restoreErr))
s.registry.Add(rec)
return nil, errors.Join(err, fmt.Errorf("plugins: restore previous plugin record: %w", restoreErr))
}
} else if deleteErr := s.store.Delete(rec.ID); deleteErr != nil {
s.log.Warn("plugins: failed to delete plugin record after approval review error",
zap.String("plugin_id", rec.ID), zap.Error(deleteErr))
s.registry.Add(rec)
return nil, errors.Join(err, fmt.Errorf("plugins: remove failed plugin record: %w", deleteErr))
}
s.rollbackFailedInstall(result.InstallPath, oldRec, hadOldRec && wasRunning)
return nil, err
}
s.registry.Add(rec)
s.agentToolInstallMu.Unlock()
catalogLocked = false
@@ -146,6 +168,18 @@ func (s *Service) Install(ctx context.Context, r io.Reader) (*store.Record, erro
return installed, activateErr
}
func (s *Service) reviewInstalledApprovals(rec *store.Record) error {
ledger := s.approvalLedger()
if ledger == nil {
return nil
}
caps, err := ManifestCapabilityIDs(rec.Manifest)
if err != nil {
return err
}
return ledger.reviewManifestChange(rec.InstallationID, ManifestCapabilityDigest(rec.Manifest), caps, time.Now().UTC(), true)
}
// extractPackage runs pkgtar.Install and registers the extracted version
// directory as an in-flight install, both under extractingMu so the directory
// is never visible on disk without being marked. Install cannot take the
@@ -354,6 +388,12 @@ func (s *Service) Uninstall(ctx context.Context, id string) error {
s.runtime.Stop(id)
}
s.revokeGitCredentialProviderLeases(rec.RepositoryProviders)
if rec.InstallationID != "" {
if err := s.approvalTombstoneInstallation(rec.InstallationID); err != nil {
s.reconcileAbortedUninstall(id, wasRunning)
return fmt.Errorf("plugins: tombstone approval history: %w", err)
}
}
if err := s.deletePluginSecrets(ctx, id); err != nil {
s.reconcileAbortedUninstall(id, wasRunning)
return fmt.Errorf("plugins: uninstall aborted, could not purge plugin secrets: %w", err)
@@ -8,9 +8,11 @@ import (
"os"
"path/filepath"
goruntime "runtime"
"strings"
"sync"
"testing"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/pkgtar"
"github.com/kandev/kandev/internal/plugins/pkgtar/pkgtartest"
"github.com/kandev/kandev/internal/plugins/store"
@@ -108,6 +110,169 @@ func TestServiceInstallOverActivePluginRestartsWithNewVersion(t *testing.T) {
}
}
func TestServiceInstallUpgradeReviewsExistingCapabilityApprovals(t *testing.T) {
svc, _, _ := newTestService(t)
rec1, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.0.0", "tasks", "messages"))
if err != nil {
t.Fatalf("install initial plugin: %v", err)
}
originalDigest := ManifestCapabilityDigest(rec1.Manifest)
if _, err := svc.approvalGrant(rec1.InstallationID, "ws-1", 1, originalDigest, []string{"host.v2.read:tasks", "host.v2.read:messages"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant initial approval: %v", err)
}
rec2, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.1.0", "tasks"))
if err != nil {
t.Fatalf("upgrade plugin: %v", err)
}
if rec2.InstallationID != rec1.InstallationID {
t.Fatalf("installation id changed on upgrade: %q -> %q", rec1.InstallationID, rec2.InstallationID)
}
row, ok, err := svc.approvalCurrent(rec1.InstallationID, "ws-1")
if err != nil || !ok {
t.Fatalf("approvalCurrent after upgrade: ok=%v err=%v", ok, err)
}
if row.Revision != 2 {
t.Fatalf("revision after upgrade = %d, want 2", row.Revision)
}
if row.ManifestDigest != ManifestCapabilityDigest(rec2.Manifest) {
t.Fatalf("manifest digest after upgrade = %q, want %q", row.ManifestDigest, ManifestCapabilityDigest(rec2.Manifest))
}
if got, want := row.CapabilityIDs, []string{"host.v2.read:tasks"}; !equalStrings(got, want) {
t.Fatalf("capabilities after narrowing upgrade = %#v, want %#v", got, want)
}
if _, err := svc.approvalGrant(rec1.InstallationID, "ws-1", 3, row.ManifestDigest, []string{"host.v2.read:tasks"}, "human", "renew", "audit-2"); err != nil {
t.Fatalf("renew approval: %v", err)
}
rec3, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.2.0", "tasks", "messages"))
if err != nil {
t.Fatalf("rollback-equivalent install: %v", err)
}
if rec3.InstallationID != rec1.InstallationID {
t.Fatalf("installation id changed on rollback-equivalent replacement: %q -> %q", rec1.InstallationID, rec3.InstallationID)
}
row, ok, err = svc.approvalCurrent(rec1.InstallationID, "ws-1")
if err != nil || !ok {
t.Fatalf("approvalCurrent after rollback-equivalent replacement: ok=%v err=%v", ok, err)
}
if row.Revision != 4 {
t.Fatalf("revision after rollback-equivalent replacement = %d, want 4", row.Revision)
}
if got, want := row.CapabilityIDs, []string{"host.v2.read:tasks"}; !equalStrings(got, want) {
t.Fatalf("capabilities after rollback-equivalent replacement = %#v, want %#v", got, want)
}
file, err := svc.approvalLedger().load()
if err != nil {
t.Fatalf("load approvals: %v", err)
}
if len(file.Events) != 4 || file.Events[1].Type != CapabilityApprovalEventUpgradeReview ||
file.Events[1].BeforeDigest != originalDigest || file.Events[1].AfterDigest != ManifestCapabilityDigest(rec2.Manifest) ||
file.Events[2].Type != CapabilityApprovalEventGrant || file.Events[3].Type != CapabilityApprovalEventUpgradeReview ||
file.Events[3].BeforeDigest != ManifestCapabilityDigest(rec2.Manifest) || file.Events[3].AfterDigest != ManifestCapabilityDigest(rec3.Manifest) {
t.Fatalf("approval events after upgrade = %#v", file.Events)
}
}
func TestServiceInstallUpgradeReviewFailureRestartsPreviousRuntime(t *testing.T) {
svc, _, rt := newTestService(t)
rec1, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.0.0", "tasks"))
if err != nil {
t.Fatalf("install initial plugin: %v", err)
}
if _, err := svc.approvalGrant(rec1.InstallationID, "ws-1", 1, ManifestCapabilityDigest(rec1.Manifest), []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant approval: %v", err)
}
// An empty manifest resource is accepted by package extraction but is
// rejected while deriving the exact approval capability set. This fails
// after the old runtime has been stopped and the new record persisted.
_, err = svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.1.0", "*"))
if err == nil {
t.Fatal("upgrade with invalid capability expected an approval review error")
}
if !rt.Running("kandev-plugin-slack") {
t.Fatal("previous runtime was not restarted after approval review failure")
}
if got := rt.startCallCount("kandev-plugin-slack"); got != 2 {
t.Fatalf("runtime Start called %d times, want initial start plus rollback restart", got)
}
onDisk, err := svc.Get("kandev-plugin-slack")
if err != nil {
t.Fatalf("get restored plugin: %v", err)
}
if onDisk.Version != "1.0.0" {
t.Fatalf("restored plugin version = %q, want 1.0.0", onDisk.Version)
}
}
func TestServiceInstallUpgradeReviewRollbackFailureKeepsNewRecordCoherent(t *testing.T) {
dir := t.TempDir()
fsStore := store.NewFSStore(dir)
failing := &failMatchingSaveStore{
Store: fsStore,
match: func(rec *store.Record) bool { return rec.Version == "1.0.0" },
failErr: errors.New("simulated compensating save failure"),
}
svc := NewService(failing, NewRegistry(), nil, testLogger(t))
if err := svc.SetPluginsDir(dir); err != nil {
t.Fatalf("SetPluginsDir: %v", err)
}
svc.SetRuntime(newFakeRuntime())
t.Cleanup(func() { _ = svc.Close() })
if _, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.0.0", "tasks")); err != nil {
t.Fatalf("install initial plugin: %v", err)
}
failing.arm()
_, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.1.0", "*"))
if err == nil || !strings.Contains(err.Error(), "compensating save failure") {
t.Fatalf("upgrade error = %v, want compensating save failure", err)
}
onDisk, getErr := fsStore.Get("kandev-plugin-slack")
if getErr != nil {
t.Fatalf("store.Get(): %v", getErr)
}
if onDisk.Version != "1.1.0" {
t.Fatalf("persisted version = %q, want retained new version 1.1.0", onDisk.Version)
}
if _, statErr := os.Stat(onDisk.InstallPath); statErr != nil {
t.Fatalf("retained new install path missing: %v", statErr)
}
current, getErr := svc.Get("kandev-plugin-slack")
if getErr != nil || current.Version != "1.1.0" {
t.Fatalf("registry record = %#v, err=%v; want coherent new record", current, getErr)
}
}
func TestServiceInstallEquivalentUpgradeBumpsApprovalRevision(t *testing.T) {
svc, _, _ := newTestService(t)
rec1, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.0.0", "tasks"))
if err != nil {
t.Fatalf("install initial plugin: %v", err)
}
digest := ManifestCapabilityDigest(rec1.Manifest)
if _, err := svc.approvalGrant(rec1.InstallationID, "ws-1", 1, digest, []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant: %v", err)
}
if _, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.1.0", "tasks")); err != nil {
t.Fatalf("equivalent upgrade: %v", err)
}
row, ok, err := svc.approvalCurrent(rec1.InstallationID, "ws-1")
if err != nil || !ok {
t.Fatalf("approvalCurrent: ok=%v err=%v", ok, err)
}
if row.Revision != 2 {
t.Fatalf("revision = %d, want 2", row.Revision)
}
file, err := svc.approvalLedger().load()
if err != nil {
t.Fatalf("load approvals: %v", err)
}
if len(file.Events) != 2 || file.Events[1].Type != CapabilityApprovalEventUpgradeReview {
t.Fatalf("events = %#v, want grant and upgrade review", file.Events)
}
}
// failingSaveStore wraps a real store.Store, letting a test arm exactly one
// Save call to fail with a simulated error — used to exercise Install's
// cleanup-on-persist-failure path without a real disk-full/permission
@@ -118,6 +283,33 @@ type failingSaveStore struct {
failNext bool
}
type failMatchingSaveStore struct {
store.Store
mu sync.Mutex
armed bool
match func(*store.Record) bool
failErr error
}
func (s *failMatchingSaveStore) Save(rec *store.Record) error {
s.mu.Lock()
fail := s.armed && s.match(rec)
if fail {
s.armed = false
}
s.mu.Unlock()
if fail {
return s.failErr
}
return s.Store.Save(rec)
}
func (s *failMatchingSaveStore) arm() {
s.mu.Lock()
s.armed = true
s.mu.Unlock()
}
func (s *failingSaveStore) Save(rec *store.Record) error {
s.mu.Lock()
fail := s.failNext
@@ -373,6 +565,44 @@ func TestServiceInstallFromURL_RejectsNonHTTPSchemeBeforeAnyRequest(t *testing.T
}
}
func testPackageWithAPIRead(t *testing.T, id, version string, resources ...string) *bytes.Buffer {
t.Helper()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
quotedResources := make([]string, 0, len(resources))
for _, resource := range resources {
quotedResources = append(quotedResources, fmt.Sprintf("%q", resource))
}
minimumVersion := ""
for _, resource := range resources {
if resource == "messages" {
minimumVersion = fmt.Sprintf("min_kandev_version: %q\n", manifest.MinimumMessagesCapabilityVersion)
break
}
}
manifestYAML := fmt.Sprintf(`
id: %s
api_version: 1
version: %s
display_name: Test Plugin
%s
capabilities:
api_read: [%s]
runtime:
type: binary
executables:
%s: server/plugin
`, id, version, minimumVersion, strings.Join(quotedResources, ", "), platformKey)
var buf bytes.Buffer
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
"manifest.yaml": []byte(manifestYAML),
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
}); err != nil {
t.Fatalf("WritePackage: %v", err)
}
return &buf
}
// messagesMinKandevVersionPackage is like minKandevVersionPackage but
// declares capabilities.api_read: messages, which subjects the manifest to
// the capability minimum floor (MinimumMessagesCapabilityVersion).
@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/plugins/manifest"
@@ -198,11 +199,12 @@ func (s *Service) registerSideload(id, version string) error {
}
rec := &store.Record{
Manifest: *m,
Status: StatusDisabled,
InstallPath: versionDir,
Signed: false,
InstalledAt: time.Now().UTC(),
Manifest: *m,
Status: StatusDisabled,
InstallationID: uuid.NewString(),
InstallPath: versionDir,
Signed: false,
InstalledAt: time.Now().UTC(),
}
if err := s.store.Save(rec); err != nil {
return fmt.Errorf("persist sideloaded record: %w", err)
+85 -2
View File
@@ -695,11 +695,17 @@ func TestServiceUninstallDeletesPluginUserStateForEveryUser(t *testing.T) {
func TestServiceUninstallFailsClosedWhenUserStateCleanupFails(t *testing.T) {
svc, fsStore, rt := newTestService(t)
svc.SetUserState(newTestUserStateStore(t))
rec := installTestPlugin(t, svc, "kandev-plugin-notes")
ctx := context.Background()
rec, err := svc.Install(ctx, testPackageWithAPIRead(t, "kandev-plugin-notes", "1.0.0", "tasks"))
if err != nil {
t.Fatalf("install plugin: %v", err)
}
if _, err := svc.UserState().Set(ctx, rec.ID, "user_1", "task", "task_1", "note", json.RawMessage(`"a"`), nil); err != nil {
t.Fatalf("seed user state: %v", err)
}
if _, err := svc.approvalGrant(rec.InstallationID, "ws-1", 1, ManifestCapabilityDigest(rec.Manifest), []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("seed approval: %v", err)
}
cleanupErr := errors.New("user state database unavailable")
cleanup := &fakeUserStateCleanup{
@@ -710,7 +716,7 @@ func TestServiceUninstallFailsClosedWhenUserStateCleanupFails(t *testing.T) {
deliverer := &fakeDeliverer{}
svc.SetDeliverer(deliverer)
err := svc.Uninstall(ctx, rec.ID)
err = svc.Uninstall(ctx, rec.ID)
if err == nil || !strings.Contains(err.Error(), cleanupErr.Error()) {
t.Fatalf("Uninstall() error = %v, want user-state cleanup failure", err)
}
@@ -732,6 +738,14 @@ func TestServiceUninstallFailsClosedWhenUserStateCleanupFails(t *testing.T) {
if deliverer.refreshCount != 1 {
t.Fatalf("deliverer refreshes after failed uninstall = %d, want stopped-state reconciliation only", deliverer.refreshCount)
}
firstApproval, ok, err := svc.approvalCurrent(rec.InstallationID, "ws-1")
if err != nil || !ok {
t.Fatalf("approvalCurrent after failed uninstall: ok=%v err=%v", ok, err)
}
if firstApproval.Revision != 2 || firstApproval.TombstonedAt == nil {
t.Fatalf("approval after failed uninstall = %#v", firstApproval)
}
firstTombstone := *firstApproval.TombstonedAt
cleanup.deleteErr = nil
if err := svc.Uninstall(ctx, rec.ID); err != nil {
@@ -750,6 +764,75 @@ func TestServiceUninstallFailsClosedWhenUserStateCleanupFails(t *testing.T) {
if len(entries) != 0 {
t.Fatalf("user state rows after successful retry = %d, want 0", len(entries))
}
secondApproval, ok, err := svc.approvalCurrent(rec.InstallationID, "ws-1")
if err != nil || !ok {
t.Fatalf("approvalCurrent after successful retry: ok=%v err=%v", ok, err)
}
if secondApproval.Revision != firstApproval.Revision || secondApproval.TombstonedAt == nil || !secondApproval.TombstonedAt.Equal(firstTombstone) {
t.Fatalf("approval after uninstall retry changed tombstone state: first=%#v second=%#v", firstApproval, secondApproval)
}
ledger, err := svc.approvalLedger().load()
if err != nil {
t.Fatalf("load approvals after successful retry: %v", err)
}
if len(ledger.Events) != 2 {
t.Fatalf("approval event count after uninstall retry = %d, want grant plus single revoke", len(ledger.Events))
}
}
func TestServiceUninstallReconcilesRuntimeStateWhenApprovalTombstoneFails(t *testing.T) {
svc, _, rt := newTestService(t)
rec, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-slack", "1.0.0", "tasks"))
if err != nil {
t.Fatalf("install plugin: %v", err)
}
if _, err := svc.approvalGrant(rec.InstallationID, "ws-1", 1, ManifestCapabilityDigest(rec.Manifest), []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("seed approval: %v", err)
}
if err := os.Remove(svc.approvalLedger().path()); err != nil {
t.Fatalf("remove approval ledger: %v", err)
}
if err := os.Mkdir(svc.approvalLedger().path(), 0o755); err != nil {
t.Fatalf("replace approval ledger with directory: %v", err)
}
err = svc.Uninstall(context.Background(), rec.ID)
if err == nil {
t.Fatal("Uninstall() expected tombstone failure")
}
if !rt.stopped(rec.ID) {
t.Fatal("Uninstall() did not stop the runtime before tombstone failure")
}
current, getErr := svc.Get(rec.ID)
if getErr != nil {
t.Fatalf("Get() after failed uninstall: %v", getErr)
}
if current.Status != StatusError {
t.Fatalf("status after failed tombstone = %q, want %q", current.Status, StatusError)
}
}
func TestServiceUninstallFencesOldApprovalAsRevokedAfterRegistryRemoval(t *testing.T) {
svc, _, _ := newTestService(t)
rec, err := svc.Install(context.Background(), testPackageWithAPIRead(t, "kandev-plugin-notes", "1.0.0", "tasks"))
if err != nil {
t.Fatalf("install: %v", err)
}
if _, err := svc.approvalGrant(rec.InstallationID, "ws-1", 1, ManifestCapabilityDigest(rec.Manifest), []string{"host.v2.read:tasks"}, "human", "grant", "audit-1"); err != nil {
t.Fatalf("grant approval: %v", err)
}
if err := svc.Uninstall(context.Background(), rec.ID); err != nil {
t.Fatalf("uninstall: %v", err)
}
decision := svc.AuthorizeCapability(rec.InstallationID, "ws-1", "host.v2.read:tasks", 1, "request-digest", "method-digest")
if decision.Allowed {
t.Fatalf("AuthorizeCapability() allowed tombstoned installation: %#v", decision)
}
if decision.Reason != ApprovalDenyRevokedApproval {
t.Fatalf("AuthorizeCapability() reason = %q, want %q", decision.Reason, ApprovalDenyRevokedApproval)
}
}
func TestServiceUninstallMissingReturnsNotFound(t *testing.T) {
+7 -3
View File
@@ -39,9 +39,13 @@ var ErrNotFound = errors.New("plugin not found")
type Record struct {
manifest.Manifest `yaml:",inline"`
Status string `yaml:"status" json:"status"`
InstallPath string `yaml:"install_path" json:"install_path"`
Signed bool `yaml:"signed" json:"signed"`
Status string `yaml:"status" json:"status"`
// InstallationID is a Host-minted opaque principal for the installed
// plugin. It is persisted with the record and never derived from the
// plugin id, package digest, workspace, or install path.
InstallationID string `yaml:"installation_id,omitempty" json:"installation_id,omitempty"`
InstallPath string `yaml:"install_path" json:"install_path"`
Signed bool `yaml:"signed" json:"signed"`
// AutoUpdate is the operator's per-plugin auto-update override. It is a
// tri-state: nil means "inherit the instance-wide default"
// (Service.AutoUpdateDefault), true forces auto-update on for this plugin,
@@ -0,0 +1,105 @@
---
created: 2026-09-07
status: done
requirements:
- 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
system_design:
- ../../specs/plugins/system-design/capability-approval.md
legacy_specs: []
---
# Implementation Plan: Generic Plugin Capability Approval and Audit (H6)
## Overview
Add the generic plugin capability-approval substrate as one vertical work
order: host-minted installation identity, the workspace-scoped approval ledger
with exact-revision admission, the append-only event trail, the fail-closed
authorization decision with typed deny reasons, uninstall tombstones with
reinstall identity, the legacy `v1` fence, and the audit receipt primitives.
Scope is the substrate only: no approval UI, no coordinator surface, and no
host receipt persistence.
## Scope
### In scope
- `installation_id` minting and persistence on the plugin store record.
- One current `CapabilityApproval` per `(installation_id, workspace_id)`.
- Grant, narrow, revoke, and upgrade-review events in an append-only ledger.
- Exact-revision admission and audit-id idempotency.
- Fail-closed `AuthorizeCapability` with stable typed deny reasons.
- Uninstall tombstone and reinstall identity mints.
- The `host.v2` exact-capability fence for legacy `v1` declarations.
- `ApprovalReceipt` and canonical digest primitives.
### Out of scope
- Approval or review UI, coordinator surfaces, dashboards.
- Host adapter persistence of audit or read/write receipts.
- Changes to legacy `v1` runtime behavior beyond fencing it from approval
authority.
- H1-H5 plugin capabilities; this initiative is substrate only.
### No UI changes
This package does not create or change rendered UI, so no ASCII UI preview is
included.
## Technical approach
The ledger (`approvals.json` under the plugins directory) owns approvals,
events, tombstones, and idempotency inputs. One current approval per
installation/workspace pair is stored keyed by the pair with exact next
revision admission; every mutation appends an event carrying before/after
revisions and digests. `installation_id` is minted at install time and
attached to the store record; reinstall mints fresh identity and tombstones
block the old identity permanently.
The authorization path (`approval_service.go`) is a pure decision function
that evaluates in a fixed order — malformed request, human-reserved, missing
approval (including the foreign-workspace alias), revoked or tombstoned,
stale revision, manifest intersection, approved list — and returns a typed
decision carrying the audit identity and a bounded receipt. Legacy `v1`
declarations keep their behavior but are fenced from approval admission and
authorization.
## Tests
- `approval_test.go`, `approval_store_test.go`, `approval_service_test.go`:
canonicalization, exact-revision admission, idempotency, tombstone, and
reinstall-identity coverage.
- `approval_api_test.go`, `approval_query_test.go`: API list/get, grant, and
revoke flows including conflict semantics.
- `approval_dto_test.go`: DTO shape and receipt bounds.
- Store install/sync tests: `installation_id` minting and persistence.
## E2E tests
No new E2E is required: the substrate exposes no UI surface. Go unit and
integration tests exercise the full ledger and decision matrix; a browser
duplicate of that matrix would add cost without observable UI coverage.
## Work orders
- [x] [Task 01: Capability approval substrate](task-01-capability-approval-substrate.md)
## Verification results
- `cd apps/backend && go build ./...` passed on the H6 head and was
re-verified on the merged head.
- `cd apps/backend && go test ./internal/plugins/...` passed in 10 packages
on the H6 head and was re-verified on the merged head.
## Risks
- Ledger file corruption must not grant authority: unknown fields decode
forward-compatibly and missing maps initialize empty, but any decode failure
must fail closed at lookup.
- A future policy revision must not silently reinterpret stored approvals:
the per-row human policy version is the stability point.
@@ -0,0 +1,146 @@
---
id: "01-capability-approval-substrate"
title: "Capability approval substrate"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- 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
acceptance_criteria:
- AC-PLUGINS-CAPABILITY-APPROVAL-001.1
- AC-PLUGINS-CAPABILITY-APPROVAL-001.2
- AC-PLUGINS-CAPABILITY-APPROVAL-001.3
- AC-PLUGINS-CAPABILITY-APPROVAL-001.4
- AC-PLUGINS-CAPABILITY-APPROVAL-001.5
- AC-PLUGINS-CAPABILITY-APPROVAL-001.6
- AC-PLUGINS-CAPABILITY-APPROVAL-002.1
- AC-PLUGINS-CAPABILITY-APPROVAL-002.2
- AC-PLUGINS-CAPABILITY-APPROVAL-003.1
- AC-PLUGINS-CAPABILITY-APPROVAL-003.2
- AC-PLUGINS-CAPABILITY-APPROVAL-003.3
- AC-PLUGINS-CAPABILITY-APPROVAL-003.4
- AC-PLUGINS-CAPABILITY-APPROVAL-003.5
- AC-PLUGINS-CAPABILITY-APPROVAL-003.6
- AC-PLUGINS-CAPABILITY-APPROVAL-003.7
- AC-PLUGINS-CAPABILITY-APPROVAL-004.1
- AC-PLUGINS-CAPABILITY-APPROVAL-004.2
- AC-PLUGINS-CAPABILITY-APPROVAL-005.1
- AC-PLUGINS-CAPABILITY-APPROVAL-005.2
- AC-PLUGINS-CAPABILITY-APPROVAL-006.1
- AC-PLUGINS-CAPABILITY-APPROVAL-006.2
- AC-PLUGINS-CAPABILITY-APPROVAL-006.3
system_design:
- ../../specs/plugins/system-design/capability-approval.md
---
# Task 01: Capability Approval Substrate
## Summary
Deliver the generic plugin capability-approval substrate: host-minted
installation identity, one current approval per `(installation_id,
workspace_id)`, append-only approval events, exact-revision admission, the
fail-closed authorization decision with typed deny reasons, uninstall
tombstones with fresh reinstall identity, the legacy `v1` fence, and the
audit receipt primitives.
## In scope
- `InstallationID` minting and persistence on the plugin store record.
- `CapabilityApproval` ledger with grant/narrow/revoke/upgrade-review events.
- Exact next-revision admission and audit-id idempotency.
- `AuthorizeCapability` fail-closed decision with stable deny vocabulary.
- Tombstones on uninstall; fresh identity on reinstall.
- `host.v2` exact-capability fence for legacy `v1` declarations.
- `ApprovalReceipt` and canonical digest primitives.
- Service and store APIs for list/get/grant/revoke.
## Out of scope
- Approval UI, coordinator surfaces, dashboards.
- Host adapter persistence of receipts.
- Legacy `v1` behavior changes beyond the fence.
- H1-H5 capabilities.
## Acceptance
- Grants replace the current row for the installation/workspace pair and are
admitted only at the exact next revision with matching manifest digest and
manifest-declared capabilities.
- Every mutation appends an event with before/after revisions and digests;
history is never rewritten.
- Authorization denies missing, revoked, tombstoned, stale, human-reserved,
unavailable, undeclared, and malformed requests with distinct typed reasons;
foreign-workspace requests alias the missing-approval reason.
- Uninstall tombstones the identity permanently; reinstall mints a new
identity, so no approval is inherited.
- Legacy `v1` capabilities are rejected from approval lists and denied at
authorization as unsupported, while retaining their original runtime
behavior.
## Verification
```bash
cd apps/backend && go build ./...
cd apps/backend && go test ./internal/plugins/...
```
## Files likely touched
- `apps/backend/internal/plugins/approval.go`
- `apps/backend/internal/plugins/approval_api.go`
- `apps/backend/internal/plugins/approval_dto.go`
- `apps/backend/internal/plugins/approval_service.go`
- `apps/backend/internal/plugins/approval_store.go`
- `apps/backend/internal/plugins/registry.go`
- `apps/backend/internal/plugins/service.go`
- `apps/backend/internal/plugins/service_install.go`
- `apps/backend/internal/plugins/service_sync.go`
- `apps/backend/internal/plugins/store/store.go`
- `apps/backend/internal/plugins/service_test.go`
- `apps/backend/internal/plugins/store/store_test.go`
## Dependencies
None. This is the substrate initiative; no prior plugin capability wave is
required.
## Risks
- Cross-workspace denial must not leak existence: the foreign-workspace path
intentionally reuses the missing-approval deny reason.
- Ledger decode failures must fail closed at lookup rather than default to
allowing.
## Parallelism
`sequential` — single vertical work order owning the approval files.
## Inputs
- Requirements `REQ-PLUGINS-CAPABILITY-APPROVAL-001` through `-006`.
- System design `docs/specs/plugins/system-design/capability-approval.md`.
- Plan `plan.md`.
- Existing manifest capability derivation (`ManifestCapabilityIDs`) and store
record patterns in `apps/backend/internal/plugins`.
## Results
Implemented the full substrate on the H6 head: installation id minting in the
install path, the approval ledger with events/tombstones/idempotency, the
fail-closed decision path with the typed deny vocabulary, reinstall identity
mints, the `v1` fence, and receipt primitives. Verified with:
```bash
cd apps/backend && go test ./internal/plugins/...
```
Result: all 10 plugin packages pass, including the approval store, service,
API, DTO, and query tests, on the H6 head and re-verified after the merge with
current main.
+6
View File
@@ -268,6 +268,12 @@ curated React, UI, and app-store surface.
- capabilities.auth is the highest-risk capability. A webhook response may
assert a verified external identity with X-Kandev-Auth-Login; only assert an
email the IdP verified as owned by the subject. See [ADR 0050](../decisions/0050-plugin-external-auth-capability.md).
- Plugin installations also carry a host-minted opaque installation identity
and a generic capability-approval ledger. That ledger records approval
history, exact approval revisions, and revocation tombstones for the host
boundary, but there is no public mutation UI in this release. Future exact
Host adapters consume the approval receipt/query surface; they do not derive
authority from plugin IDs, package digests, or workspace state.
An isolated web app has a separate browser boundary. Kandev loads it in a
sandboxed iframe with an opaque origin. It cannot use the host DOM, cookies,
@@ -0,0 +1,193 @@
---
status: active
system: plugins
created: 2026-09-07
owners:
- kandev
---
# Plugin Capability Approval and Audit Requirements
## Overview
Installed plugins request host capabilities. Kandev must gate every capability
request behind an explicit, workspace-scoped human approval, record an
append-only audit trail of approval changes, and evaluate requests against the
intersection of what the installed manifest declares, what the human approved,
and what human policy reserves. This document defines that substrate for
`host.v2` capabilities; it does not define approval UI, coordinator surfaces,
or higher-level plugin features built on it.
## Terminology
- **Installation:** One installed plugin release, identified by a host-minted
opaque `installation_id`. The identity is never derived from the plugin id,
package digest, workspace, or install path.
- **Approval:** One current capability approval row per
`(installation_id, workspace_id)` pair, associating an exact manifest digest
with a canonical list of approved capability ids.
- **Effective authority:** The capability set obtained by intersecting the
installed manifest declaration, the current approval, and the human deny
policy. A capability is allowed only when all three admit it.
- **Human deny policy:** The immutable set of human-reserved capabilities
(merge, deploy, release, history rewrite, cross-workspace access, and
secret-scope expansion) that no approval can ever grant.
- **Tombstone:** The uninstall record that permanently blocks approvals for an
`installation_id` so a reinstall mints a new identity.
## Requirements
### REQ-PLUGINS-CAPABILITY-APPROVAL-001: Workspace-scoped capability approvals
**Intent:** Give each workspace explicit, human-controlled authority over the
capabilities an installed plugin may exercise inside that workspace, with one
current approval per installation and workspace.
**User story:** As a workspace operator, I want to approve exactly the
capabilities a plugin may use in my workspace, so that plugin authority is
explicit, auditable, and never broader than what the installed manifest
declares.
#### Acceptance criteria
- **AC-PLUGINS-CAPABILITY-APPROVAL-001.1:** When a plugin installation is
recorded, the system shall assign it a host-minted opaque `installation_id`
that is not derived from the plugin id, package digest, workspace, or install
path.
- **AC-PLUGINS-CAPABILITY-APPROVAL-001.2:** The system shall keep at most one
current approval per `(installation_id, workspace_id)` pair, and a grant on an
existing pair shall replace the previous current approval.
- **AC-PLUGINS-CAPABILITY-APPROVAL-001.3:** When an approval is recorded, the
system shall reject it unless its manifest digest matches the installed
manifest and every requested capability id is declared by that manifest.
- **AC-PLUGINS-CAPABILITY-APPROVAL-001.4:** When an approval is recorded, the
system shall reject any capability id that is human-reserved, malformed, or
not an exact `host.v2` capability.
- **AC-PLUGINS-CAPABILITY-APPROVAL-001.5:** When a revision differs from the
current approval revision, the system shall reject the write with a revision
conflict and leave the stored approval unchanged.
- **AC-PLUGINS-CAPABILITY-APPROVAL-001.6:** When a grant reuses an audit id with
different inputs, the system shall reject it without changing stored state.
### REQ-PLUGINS-CAPABILITY-APPROVAL-002: Append-only approval events
**Intent:** Preserve a complete, ordered audit trail of every approval change
so authority changes can be reviewed after the fact.
**User story:** As an auditor, I want every grant, narrow, revoke, and
upgrade-review decision recorded with before/after revisions and digests, so
that I can reconstruct how plugin authority changed over time.
#### Acceptance criteria
- **AC-PLUGINS-CAPABILITY-APPROVAL-002.1:** When the system changes an
approval, it shall append an event recording the installation, workspace,
before and after revisions and digests, actor, reason, event type, and an
observed timestamp, without rewriting or removing earlier events.
- **AC-PLUGINS-CAPABILITY-APPROVAL-002.2:** The system shall support the event
types `grant`, `narrow`, `revoke`, and `upgrade_review`.
### REQ-PLUGINS-CAPABILITY-APPROVAL-003: Fail-closed capability authorization
**Intent:** Evaluate every capability request against the manifest ∩ approval
∩ human deny-policy intersection, denying unless all three admit the request.
**User story:** As a workspace operator, I want unauthorized capability requests
to fail closed with a stable machine-readable deny reason, so that stale,
revoked, cross-workspace, or undeclared requests can never succeed and can be
diagnosed reliably.
#### Acceptance criteria
- **AC-PLUGINS-CAPABILITY-APPROVAL-003.1:** When a capability request arrives
whose approval is missing, revoked, or tombstoned, the system shall deny it
with the corresponding stable deny reason and no side effects.
- **AC-PLUGINS-CAPABILITY-APPROVAL-003.2:** When a capability request names a
revision different from the current approval revision, the system shall deny
it as stale.
- **AC-PLUGINS-CAPABILITY-APPROVAL-003.3:** When a capability request comes
from an installation with no approval in the requested workspace, the system
shall deny it with the same deny reason used for missing approvals, without
disclosing that the installation exists in another workspace.
- **AC-PLUGINS-CAPABILITY-APPROVAL-003.4:** When a capability id is
human-reserved, the system shall deny it with the human-reserved deny reason.
- **AC-PLUGINS-CAPABILITY-APPROVAL-003.5:** When the installed manifest no
longer declares the requested capability, or the approval digest no longer
matches the installed manifest, the system shall deny the request as
unavailable.
- **AC-PLUGINS-CAPABILITY-APPROVAL-003.6:** When a request is structurally
malformed (unbounded or NUL-bearing identifiers, invalid digests, or an
unsupported capability form), the system shall deny it as malformed before
any approval lookup.
- **AC-PLUGINS-CAPABILITY-APPROVAL-003.7:** When a request passes every gate,
the system shall allow it and attach the audit identity and a receipt
recording the bounded decision metadata.
### REQ-PLUGINS-CAPABILITY-APPROVAL-004: Uninstall tombstones and reinstall identity
**Intent:** Make uninstalled plugin authority permanently inert and force
reinstalls to start from a fresh installation identity and fresh approvals.
**User story:** As an operator, I want an uninstalled plugin's approvals to be
permanently blocked and a reinstalled copy to require new approvals, so that
stale authority can never be silently inherited.
#### Acceptance criteria
- **AC-PLUGINS-CAPABILITY-APPROVAL-004.1:** When a plugin is uninstalled, the
system shall tombstone the installation identity and deny every later
capability request for it.
- **AC-PLUGINS-CAPABILITY-APPROVAL-004.2:** When a plugin is reinstalled, the
system shall mint a new `installation_id` so no prior approval can attach to
the new installation.
### REQ-PLUGINS-CAPABILITY-APPROVAL-005: Legacy declaration fence
**Intent:** Keep legacy `v1` capability declarations from being granted or
exercised through the H6 approval substrate while preserving their original
runtime behavior.
**User story:** As a maintainer, I want legacy v1 capabilities fenced out of
the approval substrate, so that only explicit `host.v2` capabilities can be
approved and evaluated.
#### Acceptance criteria
- **AC-PLUGINS-CAPABILITY-APPROVAL-005.1:** When a capability id is not an
exact `host.v2` capability, the system shall reject it from approval lists
and deny it at authorization time as unsupported.
- **AC-PLUGINS-CAPABILITY-APPROVAL-005.2:** Legacy `v1` declarations shall
retain their original runtime behavior without gaining H6 approval authority.
### REQ-PLUGINS-CAPABILITY-APPROVAL-006: Generic audit receipts
**Intent:** Give every authorization decision a bounded, deterministic receipt
metadata shape that a future host adapter can persist without carrying
authority.
**User story:** As a host adapter author, I want a typed, bounded receipt on
every decision, so that audit persistence can be added without redefining the
decision contract.
#### Acceptance criteria
- **AC-PLUGINS-CAPABILITY-APPROVAL-006.1:** When the system produces a
decision, the decision shall carry a receipt with bounded installation,
workspace, revision, capability, request and method digests, audit id, and
result values.
- **AC-PLUGINS-CAPABILITY-APPROVAL-006.2:** The receipt digests shall be
deterministic canonical digests of their normalized inputs.
- **AC-PLUGINS-CAPABILITY-APPROVAL-006.3:** When any receipt field would
exceed its bound, the system shall truncate or redact it so the receipt
remains bounded.
## Exclusions
- No approval or review UI (settings pages, coordinator surfaces, or
dashboards) is in scope for this substrate.
- No host adapter persistence of receipts is required; the receipt shape only
defines the metadata contract.
- No changes to legacy `v1` runtime behavior are made beyond fencing it from
approval authority.
- No cross-workspace disclosure surface exists: foreign-workspace requests are
indistinguishable from missing approvals.
@@ -0,0 +1,159 @@
---
status: current
system: plugins
requirements:
- 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
created: 2026-09-07
owners:
- 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](#identity-and-approval-ledger) |
| `REQ-PLUGINS-CAPABILITY-APPROVAL-002` | [Append-only event trail](#append-only-event-trail) |
| `REQ-PLUGINS-CAPABILITY-APPROVAL-003` | [Fail-closed authorization](#fail-closed-authorization) |
| `REQ-PLUGINS-CAPABILITY-APPROVAL-004` | [Tombstones and reinstall identity](#tombstones-and-reinstall-identity) |
| `REQ-PLUGINS-CAPABILITY-APPROVAL-005` | [Legacy v1 fence](#legacy-v1-fence) |
| `REQ-PLUGINS-CAPABILITY-APPROVAL-006` | [Audit receipts](#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.