feat: add plugin-backed canvases (#3111)

This commit is contained in:
Carlos Florêncio
2026-09-05 18:44:43 +00:00
committed by GitHub
parent 97140168d8
commit d74e85883d
274 changed files with 34222 additions and 369 deletions
+814
View File
@@ -0,0 +1,814 @@
package canvas
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
plugininstances "github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/webapp"
)
// PermissionSummary is the review-safe projection of a canvas manifest. It
// contains permission names and exact origins, never package source or state.
type PermissionSummary struct {
Reads []string `json:"reads,omitempty"`
Writes []string `json:"writes,omitempty"`
Events []string `json:"events,omitempty"`
SharedState bool `json:"shared_state"`
ExternalOrigins []string `json:"external_origins,omitempty"`
}
// PublishRequest contains a package already read from the trusted execution
// source stream. The request has no user-controlled scope identifiers.
type PublishRequest struct {
CanvasID string
Package *webapp.Package
Artifact webapp.Artifact
// ExpectedAuthority is captured by the trusted authoring boundary before
// source transfer. Durable persistence must still match it atomically.
ExpectedAuthority plugininstances.PublishAuthority
// ExpectedBaseReleaseID is set for a trusted edit session. The release
// publish transaction rejects the package if the active release changed
// after the editor materialized its source.
ExpectedBaseReleaseID string
SourceActorKind string
SourceUserID string
SourceTaskID string
SourceSessionID string
}
// PublishResult describes the release outcome without exposing package files.
type PublishResult struct {
Canvas *Canvas
Release plugininstances.Release
Activated bool
PermissionRequired bool
// ReleasePersisted tells the source owner that the artifact is now owned
// by durable release metadata, even when a post-persistence projection or
// retention step returned an error.
ReleasePersisted bool
}
// PromotionPreview is shown to a human before a task canvas becomes a
// workspace canvas.
type PromotionPreview struct {
Canvas *Canvas
SourceActorKind string `json:"source_actor_kind"`
SourceUserID string `json:"source_user_id,omitempty"`
SourceTaskID string `json:"source_task_id,omitempty"`
SourceSessionID string `json:"source_session_id,omitempty"`
Permissions PermissionSummary `json:"permissions"`
ActiveReleaseID string `json:"active_release_id"`
PermissionDigest string `json:"permission_digest"`
GrantGeneration int64 `json:"grant_generation"`
CurrentScope string `json:"current_scope"`
TargetScope string `json:"target_scope"`
Placement string `json:"placement"`
}
// authoringInstanceStore is the release/governance extension implemented by
// the durable plugin instance store. Keeping it optional preserves the small
// lifecycle fake used by the basic canvas service tests.
type authoringInstanceStore interface {
PluginInstanceStore
SetPluginID(context.Context, string, string) error
CreateRelease(context.Context, plugininstances.Release) error
ListReleases(context.Context, string) ([]plugininstances.Release, error)
ActivateRelease(context.Context, string, string) error
SetReleaseValidation(context.Context, string, string, string) error
ListGrants(context.Context, string) ([]plugininstances.Grant, error)
PromoteScopeAndGrants(context.Context, string, string, string, []plugininstances.Grant) error
ApproveRelease(context.Context, string, string, string, []plugininstances.Grant) error
}
type transactionalAuthoringStore interface {
authoringInstanceStore
WithTransaction(context.Context, func(*sqlx.Tx) error) error
CreateReleaseTx(context.Context, *sqlx.Tx, plugininstances.Release) error
SetPluginIDTx(context.Context, *sqlx.Tx, string, string) error
ActivateReleaseTx(context.Context, *sqlx.Tx, string, string) error
}
type reviewedPromotionStore interface {
PromoteScopeAndGrantsReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error
}
type conditionalReleaseStore interface {
CreateReleaseIfAuthorityTx(context.Context, *sqlx.Tx, string, plugininstances.PublishAuthority, plugininstances.Release) error
}
type legacyConditionalReleaseStore interface {
CreateReleaseIfActiveReleaseTx(context.Context, *sqlx.Tx, string, string, plugininstances.Release) error
}
// releaseRetentionStore is optional so the authoring service remains
// compatible with the narrow fakes used by lifecycle tests. The durable
// plugin instance store implements it to remove superseded release ownership
// after a publish.
type releaseRetentionStore interface {
PruneReleases(context.Context, string) error
}
type releaseRejectionStore interface {
RejectReleaseAndPrune(context.Context, string, string) error
}
// PublishPackage stores one validated package as an immutable release. A
// failed validation, quota check, or activation leaves the current active
// pointer untouched.
func (s *Service) PublishPackage(ctx context.Context, request PublishRequest) (*PublishResult, error) {
if err := s.ready(); err != nil {
return nil, err
}
store, ok := s.instances.(authoringInstanceStore)
if !ok {
return nil, ErrCanvasNotConfigured
}
instance, release, activated, err := s.preparePublishedRelease(ctx, store, request)
if err != nil {
return nil, err
}
persisted, err := persistPublishedRelease(ctx, store, instance.ID, release, activated, request.ExpectedAuthority, request.ExpectedBaseReleaseID)
if err != nil {
if persisted {
return &PublishResult{Release: release, Activated: activated, PermissionRequired: !activated, ReleasePersisted: true}, err
}
return nil, err
}
result := &PublishResult{Release: release, Activated: activated, PermissionRequired: !activated, ReleasePersisted: true}
if err := prunePublishedReleases(ctx, store, instance.ID); err != nil {
return result, err
}
resultCanvas, err := s.Get(ctx, request.CanvasID)
if err != nil {
return result, err
}
result.Canvas = resultCanvas
eventType := EventReleasePermissionRequired
if activated {
eventType = EventReleaseActivated
}
s.mu.Lock()
publisher := s.publisher
s.mu.Unlock()
publishEvent(ctx, publisher, lifecycleEvent(eventType, *resultCanvas))
return result, nil
}
func (s *Service) preparePublishedRelease(ctx context.Context, store authoringInstanceStore, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, error) {
if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" {
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas)
}
_, instance, err := s.load(ctx, request.CanvasID)
if err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, err
}
if instance.Status == StatusRemoved || instance.Status == StatusArchived {
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status)
}
if !request.ExpectedAuthority.IsZero() && request.ExpectedAuthority != instance.PublishAuthority() {
return plugininstances.Instance{}, plugininstances.Release{}, false, ErrStaleCanvasPublish
}
if request.Artifact.Digest != request.Package.Digest {
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: artifact digest does not match package", ErrInvalidCanvas)
}
if err := request.Package.Manifest.Validate(); err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("%w: invalid manifest: %v", ErrInvalidCanvas, err)
}
permissions := ManifestPermissions(request.Package.Manifest)
grants, err := store.ListGrants(ctx, instance.ID)
if err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, err
}
activated := permissionsFit(permissions, instance.ScopeKind, grants)
validationStatus, validationError := plugininstances.ValidationPendingPermission, "permission_review_required"
if activated {
validationStatus, validationError = plugininstances.ValidationValid, ""
}
manifestJSON, err := json.Marshal(request.Package.Manifest)
if err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("marshal canvas manifest: %w", err)
}
permissionsJSON, err := json.Marshal(permissions)
if err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, fmt.Errorf("marshal canvas permissions: %w", err)
}
release := plugininstances.Release{
ID: uuid.NewString(), PluginID: request.Package.Manifest.ID, InstanceID: instance.ID,
PackageDigest: request.Package.Digest, SourceKind: plugininstances.SourceLocalCanvas,
SourceActorKind: strings.TrimSpace(request.SourceActorKind), SourceUserID: strings.TrimSpace(request.SourceUserID),
SourceTaskID: strings.TrimSpace(request.SourceTaskID), SourceSessionID: strings.TrimSpace(request.SourceSessionID),
ManifestJSON: manifestJSON, DeclaredPermissionsJSON: permissionsJSON,
ArtifactPath: request.Artifact.RelativePath, ArtifactBytes: request.Artifact.Bytes,
ProtocolVersion: 1, ValidationStatus: validationStatus, ValidationError: validationError,
CreatedAt: s.nowUTC(),
}
return instance, release, activated, nil
}
func persistPublishedRelease(ctx context.Context, store authoringInstanceStore, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority, expectedBaseReleaseID string) (bool, error) {
if !expectedAuthority.IsZero() {
if expectedBaseReleaseID != "" && expectedBaseReleaseID != expectedAuthority.ActiveReleaseID {
return false, ErrStaleCanvasEdit
}
transactional, ok := store.(transactionalAuthoringStore)
conditional, supportsConditional := store.(conditionalReleaseStore)
if !ok || !supportsConditional {
return false, ErrStaleCanvasPublish
}
err := persistAuthorityRelease(ctx, transactional, conditional, instanceID, release, activated, expectedAuthority)
return err == nil, err
}
if expectedBaseReleaseID != "" {
transactional, ok := store.(transactionalAuthoringStore)
conditional, supportsConditional := store.(legacyConditionalReleaseStore)
if !ok || !supportsConditional {
return false, ErrStaleCanvasEdit
}
err := persistConditionalRelease(ctx, transactional, conditional, instanceID, release, activated, expectedBaseReleaseID)
return err == nil, err
}
if !activated {
if err := store.CreateRelease(ctx, release); err != nil {
return false, err
}
return true, nil
}
transactional, ok := store.(transactionalAuthoringStore)
if ok {
err := persistActivatedRelease(ctx, transactional, instanceID, release)
return err == nil, err
}
return persistActivatedReleaseFallback(ctx, store, instanceID, release)
}
func persistAuthorityRelease(ctx context.Context, transactional transactionalAuthoringStore, conditional conditionalReleaseStore, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority) error {
return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error {
if err := conditional.CreateReleaseIfAuthorityTx(ctx, tx, instanceID, expectedAuthority, release); err != nil {
return err
}
if !activated {
return nil
}
if err := transactional.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil {
return err
}
return transactional.ActivateReleaseTx(ctx, tx, instanceID, release.ID)
})
}
func persistConditionalRelease(ctx context.Context, transactional transactionalAuthoringStore, conditional legacyConditionalReleaseStore, instanceID string, release plugininstances.Release, activated bool, expectedBaseReleaseID string) error {
return transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error {
if err := conditional.CreateReleaseIfActiveReleaseTx(ctx, tx, instanceID, expectedBaseReleaseID, release); err != nil {
return err
}
if !activated {
return nil
}
if err := transactional.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil {
return err
}
return transactional.ActivateReleaseTx(ctx, tx, instanceID, release.ID)
})
}
func persistActivatedRelease(ctx context.Context, store transactionalAuthoringStore, instanceID string, release plugininstances.Release) error {
return store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
if err := store.CreateReleaseTx(ctx, tx, release); err != nil {
return err
}
if err := store.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil {
return err
}
return store.ActivateReleaseTx(ctx, tx, instanceID, release.ID)
})
}
func persistActivatedReleaseFallback(ctx context.Context, store authoringInstanceStore, instanceID string, release plugininstances.Release) (bool, error) {
if err := store.CreateRelease(ctx, release); err != nil {
return false, err
}
if err := store.SetPluginID(ctx, instanceID, release.PluginID); err != nil {
return true, err
}
if err := store.ActivateRelease(ctx, instanceID, release.ID); err != nil {
return true, err
}
return true, nil
}
// Releases returns release history for a canvas after checking its durable
// metadata binding.
func (s *Service) Releases(ctx context.Context, canvasID string) ([]plugininstances.Release, error) {
if err := s.ready(); err != nil {
return nil, err
}
store, ok := s.instances.(authoringInstanceStore)
if !ok {
return nil, ErrCanvasNotConfigured
}
_, instance, err := s.load(ctx, canvasID)
if err != nil {
return nil, err
}
return store.ListReleases(ctx, instance.ID)
}
// PromotionPreview builds the human review projection for a task-scoped
// canvas. It does not mutate scope, grants, or release state.
func (s *Service) PromotionPreview(ctx context.Context, canvasID string) (*PromotionPreview, error) {
if err := s.ready(); err != nil {
return nil, err
}
store, ok := s.instances.(authoringInstanceStore)
if !ok {
return nil, ErrCanvasNotConfigured
}
canvas, err := s.Get(ctx, canvasID)
if err != nil {
return nil, err
}
if canvas.ScopeKind != ScopeTask || canvas.ActiveReleaseID == "" || canvas.ActiveReleaseStatus != ValidationValid {
return nil, fmt.Errorf("%w: canvas must have a valid task-scoped release", ErrInvalidCanvasState)
}
release, err := store.GetRelease(ctx, canvas.ActiveReleaseID)
if err != nil {
return nil, err
}
m, err := manifestFromRelease(release)
if err != nil {
return nil, err
}
return &PromotionPreview{
Canvas: canvas,
SourceActorKind: release.SourceActorKind,
SourceUserID: release.SourceUserID,
SourceTaskID: release.SourceTaskID,
SourceSessionID: release.SourceSessionID,
Permissions: ManifestPermissions(m),
ActiveReleaseID: release.ID,
PermissionDigest: PermissionDigest(release),
GrantGeneration: canvas.GrantGeneration,
CurrentScope: ScopeTask,
TargetScope: ScopeWorkspace,
Placement: manifest.WebAppPlacementWorkspace,
}, nil
}
// PromoteCanvas atomically approves the active declaration and changes the
// instance scope with the canvas metadata provenance. The durable store
// transaction keeps both lifecycle authorities consistent after a crash.
func (s *Service) PromoteCanvas(ctx context.Context, canvasID, userID string) (*Canvas, error) {
return s.PromoteCanvasReviewed(ctx, canvasID, userID, "", "", 0)
}
// PromoteCanvasReviewed atomically applies a promotion only when the release
// and permission projection still match the human review response.
func (s *Service) PromoteCanvasReviewed(ctx context.Context, canvasID, userID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
store, ok := s.instances.(authoringInstanceStore)
if !ok {
return nil, ErrCanvasNotConfigured
}
if strings.TrimSpace(userID) == "" {
return nil, fmt.Errorf("%w: approving user is required", ErrInvalidCanvas)
}
preview, err := s.PromotionPreview(ctx, canvasID)
if err != nil {
return nil, err
}
grants := grantsForManifest(preview.Permissions, userID, ScopeWorkspace)
promotedAt := s.nowUTC()
if err := s.promoteInstanceForReview(ctx, store, preview, canvasID, userID, grants, promotedAt, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil {
return nil, err
}
updated, err := s.Get(ctx, canvasID)
if err != nil {
return nil, err
}
s.mu.Lock()
publisher := s.publisher
s.mu.Unlock()
publishEvent(ctx, publisher, lifecycleEvent(EventPromoted, *updated))
return updated, nil
}
func (s *Service) promoteInstanceForReview(ctx context.Context, store authoringInstanceStore, preview *PromotionPreview, canvasID, userID string, grants []plugininstances.Grant, promotedAt time.Time, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
reviewed := expectedReleaseID != "" || expectedPermissionDigest != "" || expectedGrantGeneration > 0
transactional, isTransactional := store.(transactionalPluginInstanceStore)
if isTransactional {
err := transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error {
if err := promoteInstanceTx(ctx, tx, transactional, preview, userID, grants, reviewed, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil {
return err
}
return s.repo.PromoteTx(ctx, tx, canvasID, userID, promotedAt)
})
return err
}
if reviewed {
return ErrStalePromotionReview
}
if err := store.PromoteScopeAndGrants(ctx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants); err != nil {
return err
}
if err := s.repo.Promote(ctx, canvasID, userID, promotedAt); err != nil {
return errors.Join(ErrCanvasMetadataBroken, err)
}
return nil
}
func promoteInstanceTx(ctx context.Context, tx *sqlx.Tx, store transactionalPluginInstanceStore, preview *PromotionPreview, userID string, grants []plugininstances.Grant, reviewed bool, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
if reviewed {
reviewedStore, ok := store.(reviewedPromotionStore)
if !ok {
return ErrStalePromotionReview
}
return reviewedStore.PromoteScopeAndGrantsReviewedTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration)
}
return store.PromoteScopeAndGrantsTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants)
}
// ApproveRelease grants the requested permissions and activates one pending
// release without changing the canvas scope.
func (s *Service) ApproveRelease(ctx context.Context, canvasID, releaseID, userID string) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
store, ok := s.instances.(authoringInstanceStore)
if !ok {
return nil, ErrCanvasNotConfigured
}
canvas, err := s.Get(ctx, canvasID)
if err != nil {
return nil, err
}
if err := releaseMutationStateError(canvas.Status); err != nil {
return nil, err
}
release, err := store.GetRelease(ctx, releaseID)
if err != nil {
return nil, err
}
if release.InstanceID != canvas.PluginInstanceID {
return nil, plugininstances.ErrInvalidRelease
}
m, err := manifestFromRelease(release)
if err != nil {
return nil, err
}
if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.ScopeKind)); err != nil {
return nil, err
}
if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil {
return nil, err
}
updated, err := s.Get(ctx, canvasID)
if err != nil {
return nil, err
}
s.mu.Lock()
publisher := s.publisher
s.mu.Unlock()
publishEvent(ctx, publisher, lifecycleEvent(EventReleaseActivated, *updated))
return updated, nil
}
// RejectRelease keeps the active release untouched and records a safe
// rejection diagnostic on the pending release.
func (s *Service) RejectRelease(ctx context.Context, canvasID, releaseID string) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
store, ok := s.instances.(authoringInstanceStore)
if !ok {
return nil, ErrCanvasNotConfigured
}
canvas, err := s.Get(ctx, canvasID)
if err != nil {
return nil, err
}
if err := releaseMutationStateError(canvas.Status); err != nil {
return nil, err
}
release, err := store.GetRelease(ctx, releaseID)
if err != nil {
return nil, err
}
if release.InstanceID != canvas.PluginInstanceID || release.ValidationStatus != ValidationPendingPermission {
return nil, plugininstances.ErrInvalidRelease
}
if rejection, ok := store.(releaseRejectionStore); ok {
if err := rejection.RejectReleaseAndPrune(ctx, canvas.PluginInstanceID, releaseID); err != nil {
return nil, err
}
} else {
if err := store.SetReleaseValidation(ctx, releaseID, plugininstances.ValidationInvalid, "rejected_by_user"); err != nil {
return nil, err
}
if err := prunePublishedReleases(ctx, store, canvas.PluginInstanceID); err != nil {
return nil, err
}
}
return s.Get(ctx, canvasID)
}
// RollbackRelease selects a retained valid release and rechecks its required
// permissions against current grants before moving the active pointer.
func (s *Service) RollbackRelease(ctx context.Context, canvasID, releaseID string) (*Canvas, error) {
if err := s.ready(); err != nil {
return nil, err
}
store, ok := s.instances.(authoringInstanceStore)
if !ok {
return nil, ErrCanvasNotConfigured
}
canvas, err := s.Get(ctx, canvasID)
if err != nil {
return nil, err
}
if err := releaseMutationStateError(canvas.Status); err != nil {
return nil, err
}
instance, err := store.Get(ctx, canvas.PluginInstanceID)
if err != nil {
return nil, err
}
releases, err := store.ListReleases(ctx, instance.ID)
if err != nil {
return nil, err
}
release, err := selectRollbackRelease(ctx, store, instance, releases, releaseID)
if err != nil {
return nil, err
}
m, err := manifestFromRelease(release)
if err != nil {
return nil, err
}
grants, err := store.ListGrants(ctx, instance.ID)
if err != nil {
return nil, err
}
if !permissionsFit(ManifestPermissions(m), instance.ScopeKind, grants) {
return nil, fmt.Errorf("%w: rollback requires permission review", plugininstances.ErrInvalidRelease)
}
if err := store.ActivateRelease(ctx, instance.ID, release.ID); err != nil {
return nil, err
}
if err := prunePublishedReleases(ctx, store, instance.ID); err != nil {
return nil, err
}
updated, err := s.Get(ctx, canvasID)
if err != nil {
return nil, err
}
s.mu.Lock()
publisher := s.publisher
s.mu.Unlock()
publishEvent(ctx, publisher, lifecycleEvent(EventReleaseActivated, *updated))
return updated, nil
}
func selectRollbackRelease(ctx context.Context, store authoringInstanceStore, instance plugininstances.Instance, releases []plugininstances.Release, releaseID string) (plugininstances.Release, error) {
if releaseID == "" {
for _, candidate := range releases {
if candidate.ID != instance.ActiveReleaseID && candidate.ValidationStatus == ValidationValid {
releaseID = candidate.ID
break
}
}
}
if releaseID == "" {
return plugininstances.Release{}, fmt.Errorf("%w: no retained valid release", plugininstances.ErrInvalidRelease)
}
release, err := store.GetRelease(ctx, releaseID)
if err != nil {
return plugininstances.Release{}, err
}
if release.InstanceID != instance.ID || release.ID == instance.ActiveReleaseID || release.ValidationStatus != ValidationValid {
return plugininstances.Release{}, plugininstances.ErrInvalidRelease
}
return release, nil
}
func prunePublishedReleases(ctx context.Context, store authoringInstanceStore, instanceID string) error {
retention, ok := store.(releaseRetentionStore)
if !ok {
return nil
}
return retention.PruneReleases(ctx, instanceID)
}
func releaseMutationStateError(status string) error {
if status == StatusArchived || status == StatusDisabled {
return fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, status)
}
return nil
}
// ManifestPermissions converts the manifest into the review/runtime shape.
func ManifestPermissions(m *manifest.Manifest) PermissionSummary {
if m == nil {
return PermissionSummary{}
}
origins := nonEmptyStrings([]string{m.BaseURL})
seenOrigins := make(map[string]struct{}, len(origins))
for _, origin := range origins {
seenOrigins[origin] = struct{}{}
}
for _, app := range m.UI.WebApps {
normalized, err := webapp.NormalizeNetworkOrigins(app.NetworkOrigins)
if err != nil {
continue
}
for _, origin := range normalized {
if _, exists := seenOrigins[origin]; exists {
continue
}
seenOrigins[origin] = struct{}{}
origins = append(origins, origin)
}
}
return PermissionSummary{
Reads: append([]string(nil), m.Capabilities.APIRead...),
Writes: append([]string(nil), m.Capabilities.APIWrite...),
Events: append([]string(nil), m.Capabilities.Events...),
SharedState: m.Capabilities.State,
ExternalOrigins: origins,
}
}
func manifestFromRelease(release plugininstances.Release) (*manifest.Manifest, error) {
var m manifest.Manifest
if err := json.Unmarshal(release.ManifestJSON, &m); err != nil {
return nil, fmt.Errorf("parse release manifest: %w", err)
}
return &m, nil
}
// ReleasePermissionSummary returns the review-safe declaration persisted with
// a release. Older rows used a flat permission-key array, which remains
// readable so a release review never falls back to an empty declaration.
func ReleasePermissionSummary(release plugininstances.Release) PermissionSummary {
var summary PermissionSummary
if err := json.Unmarshal(release.DeclaredPermissionsJSON, &summary); err == nil {
return summary
}
var legacy []string
if err := json.Unmarshal(release.DeclaredPermissionsJSON, &legacy); err != nil {
return summary
}
for _, permission := range legacy {
parts := strings.SplitN(permission, ":", 2)
if len(parts) != 2 {
continue
}
switch parts[0] {
case permissionKindAPIRead:
summary.Reads = append(summary.Reads, parts[1])
case permissionKindAPIWrite:
summary.Writes = append(summary.Writes, parts[1])
case permissionKindEvents:
summary.Events = append(summary.Events, parts[1])
case permissionKindNetwork:
summary.ExternalOrigins = append(summary.ExternalOrigins, parts[1])
case permissionKindState:
summary.SharedState = true
}
}
return summary
}
// PermissionDigest returns the digest of the exact persisted declaration. It
// is safe to show to a reviewer and lets confirmation reject a changed
// declaration without exposing its manifest or source package.
func PermissionDigest(release plugininstances.Release) string {
digest := sha256.Sum256([]byte(strings.TrimSpace(string(release.DeclaredPermissionsJSON))))
return fmt.Sprintf("%x", digest[:])
}
// MissingPermissionKeys reports declaration keys not covered by the current
// grants at the release scope. The result is sorted for stable API output.
func MissingPermissionKeys(summary PermissionSummary, scope string, grants []plugininstances.Grant) []string {
missing := make([]string, 0)
for _, permission := range permissionKeys(summary) {
if !grantCovers(permission, scope, grants) {
missing = append(missing, permission)
}
}
sort.Strings(missing)
return missing
}
func permissionKeys(summary PermissionSummary) []string {
keys := make([]string, 0, len(summary.Reads)+len(summary.Writes)+len(summary.Events)+len(summary.ExternalOrigins)+1)
for _, value := range summary.Reads {
keys = append(keys, permissionKindAPIRead+":"+value)
}
for _, value := range summary.Writes {
keys = append(keys, permissionKindAPIWrite+":"+value)
}
for _, value := range summary.Events {
keys = append(keys, permissionKindEvents+":"+value)
}
for _, value := range summary.ExternalOrigins {
keys = append(keys, permissionKindNetwork+":"+value)
}
if summary.SharedState {
keys = append(keys, permissionKindState)
}
return keys
}
func permissionsFit(summary PermissionSummary, scope string, grants []plugininstances.Grant) bool {
if len(permissionKeys(summary)) == 0 {
return true
}
for _, required := range permissionKeys(summary) {
if !grantCovers(required, scope, grants) {
return false
}
}
return true
}
func grantCovers(permission, scope string, grants []plugininstances.Grant) bool {
parts := strings.SplitN(permission, ":", 2)
kind, resource := parts[0], ""
if len(parts) == 2 {
resource = parts[1]
}
for _, grant := range grants {
if kind == permissionKindNetwork {
if grant.PermissionKind == kind && grant.NetworkOrigin == resource && grantScopeCovers(grant.ScopeCeiling, scope) {
return true
}
continue
}
if grant.PermissionKind == kind && grant.Resource == resource && grantScopeCovers(grant.ScopeCeiling, scope) {
return true
}
}
return false
}
func grantScopeCovers(ceiling, scope string) bool {
return ceiling == plugininstances.ScopeInstance || ceiling == scope
}
func grantsForManifest(summary PermissionSummary, userID, scope string) []plugininstances.Grant {
grantScope := scope
if grantScope == "" {
grantScope = ScopeWorkspace
}
now := time.Now().UTC()
grants := make([]plugininstances.Grant, 0, len(permissionKeys(summary)))
for _, permission := range permissionKeys(summary) {
parts := strings.SplitN(permission, ":", 2)
resource := ""
networkOrigin := ""
if len(parts) == 2 {
resource = parts[1]
if parts[0] == permissionKindNetwork {
networkOrigin = resource
resource = ""
}
}
grants = append(grants, plugininstances.Grant{
PermissionKind: parts[0], Resource: resource, NetworkOrigin: networkOrigin, ScopeCeiling: grantScope,
ApprovedBy: userID, ApprovedAt: now,
})
}
return grants
}
func nonEmptyStrings(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if strings.TrimSpace(value) != "" && !containsString(result, value) {
result = append(result, value)
}
}
return result
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}