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