diff --git a/apps/backend/internal/plugins/approval.go b/apps/backend/internal/plugins/approval.go new file mode 100644 index 0000000000..836cf29b58 --- /dev/null +++ b/apps/backend/internal/plugins/approval.go @@ -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) +} diff --git a/apps/backend/internal/plugins/approval_api.go b/apps/backend/internal/plugins/approval_api.go new file mode 100644 index 0000000000..2e4eea86dc --- /dev/null +++ b/apps/backend/internal/plugins/approval_api.go @@ -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 +} diff --git a/apps/backend/internal/plugins/approval_api_test.go b/apps/backend/internal/plugins/approval_api_test.go new file mode 100644 index 0000000000..c89b347282 --- /dev/null +++ b/apps/backend/internal/plugins/approval_api_test.go @@ -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") + } +} diff --git a/apps/backend/internal/plugins/approval_dto.go b/apps/backend/internal/plugins/approval_dto.go new file mode 100644 index 0000000000..33e0ddd9de --- /dev/null +++ b/apps/backend/internal/plugins/approval_dto.go @@ -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, + } +} diff --git a/apps/backend/internal/plugins/approval_dto_test.go b/apps/backend/internal/plugins/approval_dto_test.go new file mode 100644 index 0000000000..3de4a19d62 --- /dev/null +++ b/apps/backend/internal/plugins/approval_dto_test.go @@ -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) + } +} diff --git a/apps/backend/internal/plugins/approval_query_test.go b/apps/backend/internal/plugins/approval_query_test.go new file mode 100644 index 0000000000..74e9d02568 --- /dev/null +++ b/apps/backend/internal/plugins/approval_query_test.go @@ -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) + } +} diff --git a/apps/backend/internal/plugins/approval_service.go b/apps/backend/internal/plugins/approval_service.go new file mode 100644 index 0000000000..ab2f1e7068 --- /dev/null +++ b/apps/backend/internal/plugins/approval_service.go @@ -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 +} diff --git a/apps/backend/internal/plugins/approval_store.go b/apps/backend/internal/plugins/approval_store.go new file mode 100644 index 0000000000..c24745ba79 --- /dev/null +++ b/apps/backend/internal/plugins/approval_store.go @@ -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) +} diff --git a/apps/backend/internal/plugins/approval_store_sync_unix.go b/apps/backend/internal/plugins/approval_store_sync_unix.go new file mode 100644 index 0000000000..ef03511816 --- /dev/null +++ b/apps/backend/internal/plugins/approval_store_sync_unix.go @@ -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() +} diff --git a/apps/backend/internal/plugins/approval_store_sync_windows.go b/apps/backend/internal/plugins/approval_store_sync_windows.go new file mode 100644 index 0000000000..7594d0b35c --- /dev/null +++ b/apps/backend/internal/plugins/approval_store_sync_windows.go @@ -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 +} diff --git a/apps/backend/internal/plugins/approval_store_test.go b/apps/backend/internal/plugins/approval_store_test.go new file mode 100644 index 0000000000..dd41ff05a0 --- /dev/null +++ b/apps/backend/internal/plugins/approval_store_test.go @@ -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) + } +} diff --git a/apps/backend/internal/plugins/approval_store_windows_test.go b/apps/backend/internal/plugins/approval_store_windows_test.go new file mode 100644 index 0000000000..7d6e25c160 --- /dev/null +++ b/apps/backend/internal/plugins/approval_store_windows_test.go @@ -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) + } +} diff --git a/apps/backend/internal/plugins/approval_test.go b/apps/backend/internal/plugins/approval_test.go new file mode 100644 index 0000000000..2d6c196812 --- /dev/null +++ b/apps/backend/internal/plugins/approval_test.go @@ -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) + } + }) + } +} diff --git a/apps/backend/internal/plugins/provider.go b/apps/backend/internal/plugins/provider.go index bbf7d74d21..a810f902b5 100644 --- a/apps/backend/internal/plugins/provider.go +++ b/apps/backend/internal/plugins/provider.go @@ -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) diff --git a/apps/backend/internal/plugins/registry.go b/apps/backend/internal/plugins/registry.go index 5e4a9de758..ea0ea44037 100644 --- a/apps/backend/internal/plugins/registry.go +++ b/apps/backend/internal/plugins/registry.go @@ -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. diff --git a/apps/backend/internal/plugins/registry_test.go b/apps/backend/internal/plugins/registry_test.go index 1522fa881c..87a03a21e8 100644 --- a/apps/backend/internal/plugins/registry_test.go +++ b/apps/backend/internal/plugins/registry_test.go @@ -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 { diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index bf5089b784..55dbba6828 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -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"), diff --git a/apps/backend/internal/plugins/service_install.go b/apps/backend/internal/plugins/service_install.go index 7c0ddc84d4..40e1b84e01 100644 --- a/apps/backend/internal/plugins/service_install.go +++ b/apps/backend/internal/plugins/service_install.go @@ -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) diff --git a/apps/backend/internal/plugins/service_install_test.go b/apps/backend/internal/plugins/service_install_test.go index a5438ed3fd..3328eae75f 100644 --- a/apps/backend/internal/plugins/service_install_test.go +++ b/apps/backend/internal/plugins/service_install_test.go @@ -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). diff --git a/apps/backend/internal/plugins/service_sync.go b/apps/backend/internal/plugins/service_sync.go index 9a7052ff0a..a8716ad7ea 100644 --- a/apps/backend/internal/plugins/service_sync.go +++ b/apps/backend/internal/plugins/service_sync.go @@ -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) diff --git a/apps/backend/internal/plugins/service_test.go b/apps/backend/internal/plugins/service_test.go index bb14b9e8dc..eda1e579ba 100644 --- a/apps/backend/internal/plugins/service_test.go +++ b/apps/backend/internal/plugins/service_test.go @@ -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) { diff --git a/apps/backend/internal/plugins/store/store.go b/apps/backend/internal/plugins/store/store.go index beeed80fc6..84a7f0d633 100644 --- a/apps/backend/internal/plugins/store/store.go +++ b/apps/backend/internal/plugins/store/store.go @@ -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, diff --git a/docs/plans/plugins-capability-approval/plan.md b/docs/plans/plugins-capability-approval/plan.md new file mode 100644 index 0000000000..8c8f523c44 --- /dev/null +++ b/docs/plans/plugins-capability-approval/plan.md @@ -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. diff --git a/docs/plans/plugins-capability-approval/task-01-capability-approval-substrate.md b/docs/plans/plugins-capability-approval/task-01-capability-approval-substrate.md new file mode 100644 index 0000000000..b73f63b6aa --- /dev/null +++ b/docs/plans/plugins-capability-approval/task-01-capability-approval-substrate.md @@ -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. diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 218fdaa58e..d148df3182 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -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, diff --git a/docs/specs/plugins/requirements/capability-approval.md b/docs/specs/plugins/requirements/capability-approval.md new file mode 100644 index 0000000000..8e184c4fcf --- /dev/null +++ b/docs/specs/plugins/requirements/capability-approval.md @@ -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. diff --git a/docs/specs/plugins/system-design/capability-approval.md b/docs/specs/plugins/system-design/capability-approval.md new file mode 100644 index 0000000000..910d499b5f --- /dev/null +++ b/docs/specs/plugins/system-design/capability-approval.md @@ -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:` / `host.v2.write:` 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.