Files
kandev/apps/backend/internal/plugins/approval.go
T
2026-09-15 23:16:06 +01:00

137 lines
5.2 KiB
Go

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