cb9752b1a2
Co-authored-by: ayattara <alassane.yattara@savoirfairelinux.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
137 lines
5.2 KiB
Go
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)
|
|
}
|