1214 lines
48 KiB
Go
1214 lines
48 KiB
Go
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
|
|
SourceUserSynthetic bool
|
|
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"`
|
|
CurrentDataScope string `json:"current_data_scope_kind"`
|
|
TargetDataScope string `json:"target_data_scope_kind"`
|
|
Placement string `json:"placement"`
|
|
}
|
|
|
|
// WorkspaceDataPreview is the owner-facing snapshot for widening a retained
|
|
// task canvas's data scope without changing its placement.
|
|
type WorkspaceDataPreview struct {
|
|
Canvas *Canvas `json:"canvas"`
|
|
Permissions PermissionSummary `json:"permissions"`
|
|
ActiveReleaseID string `json:"active_release_id"`
|
|
PermissionDigest string `json:"permission_digest"`
|
|
GrantGeneration int64 `json:"grant_generation"`
|
|
CurrentDataScopeKind string `json:"current_data_scope_kind"`
|
|
TargetDataScopeKind string `json:"target_data_scope_kind"`
|
|
}
|
|
|
|
// 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
|
|
CreateTx(context.Context, *sqlx.Tx, plugininstances.Instance) 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
|
|
ApproveReleaseTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant) error
|
|
}
|
|
|
|
type reviewedPromotionStore interface {
|
|
PromoteScopeAndGrantsReviewedTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant, string, string, int64) error
|
|
}
|
|
|
|
type reviewedWorkspaceDataStore interface {
|
|
EnableWorkspaceDataReviewedTx(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 initialGrantStore interface {
|
|
AddInitialGrantsTx(context.Context, *sqlx.Tx, string, string, string, []plugininstances.Grant) error
|
|
}
|
|
|
|
type initialPublicationDataScopeStore interface {
|
|
SetInitialPublicationDataScopeTx(context.Context, *sqlx.Tx, plugininstances.PublishAuthority, string) error
|
|
}
|
|
|
|
type creationAuthorityStore interface {
|
|
ConsumeCreationAuthorityTx(context.Context, *sqlx.Tx, CreationAuthority, string, string, string, bool) 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, creationAuthority, initialGrants, err := s.preparePublishedRelease(ctx, store, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
persisted, err := persistPublishedRelease(ctx, store, s.repo, request.CanvasID, instance.ID, release, activated, request.ExpectedAuthority, request.ExpectedBaseReleaseID, creationAuthority, initialGrants, request.SourceUserID, request.SourceUserSynthetic, request.SourceSessionID, request.SourceTaskID)
|
|
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
|
|
}
|
|
|
|
// InstallCanvasPackage commits the canvas instance, its first release, and
|
|
// the durable installation receipt in the same lifecycle transaction. The
|
|
// artifact is written before entering the transaction and is owned by the
|
|
// release row only after this method commits.
|
|
func (s *Service) InstallCanvasPackage(ctx context.Context, request InstallCanvasPackageRequest) (*InstallResult, error) {
|
|
if err := s.ready(); err != nil {
|
|
return nil, err
|
|
}
|
|
store, createRequest, err := s.prepareCanvasInstall(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
result, event, err := s.installCanvasPackageLocked(ctx, store, createRequest, request)
|
|
publisher := s.publisher
|
|
s.mu.Unlock()
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
publishEvent(ctx, publisher, event)
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) prepareCanvasInstall(request InstallCanvasPackageRequest) (transactionalAuthoringStore, CreateCanvasRequest, error) {
|
|
if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" || s.repo == nil {
|
|
return nil, CreateCanvasRequest{}, fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas)
|
|
}
|
|
if !request.Approved || strings.TrimSpace(request.Receipt.UserID) == "" {
|
|
return nil, CreateCanvasRequest{}, ErrInstallNotApproved
|
|
}
|
|
store, ok := s.instances.(transactionalAuthoringStore)
|
|
if !ok {
|
|
return nil, CreateCanvasRequest{}, ErrCanvasNotConfigured
|
|
}
|
|
createRequest, err := normalizeCreateRequest(CreateCanvasRequest{WorkspaceID: request.WorkspaceID, Title: request.Title, PluginID: CanvasPluginID})
|
|
if err != nil {
|
|
return nil, CreateCanvasRequest{}, err
|
|
}
|
|
return store, createRequest, nil
|
|
}
|
|
|
|
func (s *Service) installCanvasPackageLocked(ctx context.Context, store transactionalAuthoringStore, createRequest CreateCanvasRequest, request InstallCanvasPackageRequest) (*InstallResult, LifecycleEvent, error) {
|
|
now := s.nowUTC()
|
|
metadata := CanvasMetadata{ID: uuid.NewString(), PluginInstanceID: uuid.NewString(), WorkspaceID: createRequest.WorkspaceID, Title: createRequest.Title, CreatedAt: now, UpdatedAt: now}
|
|
instance := plugininstances.Instance{ID: metadata.PluginInstanceID, PluginID: createRequest.PluginID, SourceKind: plugininstances.SourceLocalCanvas, ScopeKind: ScopeWorkspace, WorkspaceID: createRequest.WorkspaceID, Status: plugininstances.StatusPending, CreatedAt: now, UpdatedAt: now}
|
|
publishRequest := PublishRequest{CanvasID: metadata.ID, Package: request.Package, Artifact: request.Artifact, SourceActorKind: request.SourceActorKind, SourceUserID: request.SourceUserID}
|
|
preparedInstance, release, activated, err := s.preparePublishedReleaseForInstance(ctx, store, instance, publishRequest)
|
|
if err != nil {
|
|
return nil, LifecycleEvent{}, err
|
|
}
|
|
if preparedInstance.ID != instance.ID {
|
|
return nil, LifecycleEvent{}, ErrInvalidCanvas
|
|
}
|
|
receipt := request.Receipt
|
|
receipt.CanvasID = metadata.ID
|
|
receipt.WorkspaceID = metadata.WorkspaceID
|
|
if receipt.CreatedAt.IsZero() {
|
|
receipt.CreatedAt = now
|
|
}
|
|
if err := s.commitCanvasInstallation(ctx, store, instance, metadata, release, activated, receipt, request.Package.Manifest); err != nil {
|
|
return nil, LifecycleEvent{}, err
|
|
}
|
|
installed, err := s.Get(ctx, metadata.ID)
|
|
if err != nil {
|
|
return &InstallResult{Receipt: receipt}, LifecycleEvent{}, err
|
|
}
|
|
return &InstallResult{Canvas: installed, Receipt: receipt}, lifecycleEvent(EventReleaseActivated, *installed), nil
|
|
}
|
|
|
|
func (s *Service) commitCanvasInstallation(ctx context.Context, store transactionalAuthoringStore, instance plugininstances.Instance, metadata CanvasMetadata, release plugininstances.Release, activated bool, receipt InstallReceipt, canvasManifest *manifest.Manifest) error {
|
|
return store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
|
|
if err := store.CreateTx(ctx, tx, instance); err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.CreateTx(ctx, tx, metadata); err != nil {
|
|
return err
|
|
}
|
|
if err := store.CreateReleaseTx(ctx, tx, release); err != nil {
|
|
return err
|
|
}
|
|
if err := activateOrApproveCanvasRelease(ctx, store, tx, instance.ID, release, activated, receipt.UserID, canvasManifest); err != nil {
|
|
return err
|
|
}
|
|
return s.repo.CreateInstallReceiptTx(ctx, tx, receipt)
|
|
})
|
|
}
|
|
|
|
func activateOrApproveCanvasRelease(ctx context.Context, store transactionalAuthoringStore, tx *sqlx.Tx, instanceID string, release plugininstances.Release, activated bool, userID string, canvasManifest *manifest.Manifest) error {
|
|
if !activated {
|
|
return store.ApproveReleaseTx(ctx, tx, instanceID, release.ID, userID, grantsForManifest(ManifestPermissions(canvasManifest), userID, ScopeWorkspace))
|
|
}
|
|
if err := store.SetPluginIDTx(ctx, tx, instanceID, release.PluginID); err != nil {
|
|
return err
|
|
}
|
|
return store.ActivateReleaseTx(ctx, tx, instanceID, release.ID)
|
|
}
|
|
|
|
func (s *Service) preparePublishedRelease(ctx context.Context, store authoringInstanceStore, request PublishRequest) (plugininstances.Instance, plugininstances.Release, bool, CreationAuthority, []plugininstances.Grant, error) {
|
|
if err := validatePublishRequest(request); err != nil {
|
|
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
|
|
}
|
|
_, instance, err := s.load(ctx, request.CanvasID)
|
|
if err != nil {
|
|
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
|
|
}
|
|
if err := validatePublishInstance(instance, request.ExpectedAuthority); err != nil {
|
|
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
|
|
}
|
|
permissions := ManifestPermissions(request.Package.Manifest)
|
|
grants, releases, err := loadPublicationState(ctx, store, instance.ID)
|
|
if err != nil {
|
|
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
|
|
}
|
|
creationAuthority, initialGrants, err := s.resolveCreationAuthority(ctx, request, instance, permissions, grants, releases)
|
|
if err != nil {
|
|
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
|
|
}
|
|
activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants)
|
|
if creationAuthority.CanvasID != "" {
|
|
activated = true
|
|
}
|
|
release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated)
|
|
if err != nil {
|
|
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
|
|
}
|
|
return instance, release, activated, creationAuthority, initialGrants, nil
|
|
}
|
|
|
|
func (s *Service) preparePublishedReleaseForInstance(ctx context.Context, store authoringInstanceStore, instance plugininstances.Instance, 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)
|
|
}
|
|
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.EffectiveDataScopeKind(), grants)
|
|
release, err := s.buildPublishedRelease(request, instance.ID, permissions, activated)
|
|
if err != nil {
|
|
return plugininstances.Instance{}, plugininstances.Release{}, false, err
|
|
}
|
|
return instance, release, activated, nil
|
|
}
|
|
|
|
func validatePublishRequest(request PublishRequest) error {
|
|
if request.Package == nil || request.Package.Manifest == nil || request.Artifact.Digest == "" {
|
|
return fmt.Errorf("%w: package and artifact are required", ErrInvalidCanvas)
|
|
}
|
|
if request.Artifact.Digest != request.Package.Digest {
|
|
return fmt.Errorf("%w: artifact digest does not match package", ErrInvalidCanvas)
|
|
}
|
|
if err := request.Package.Manifest.Validate(); err != nil {
|
|
return fmt.Errorf("%w: invalid manifest: %v", ErrInvalidCanvas, err)
|
|
}
|
|
return validateLocalCanvasManifest(request.Package.Manifest)
|
|
}
|
|
|
|
func validatePublishInstance(instance plugininstances.Instance, expectedAuthority plugininstances.PublishAuthority) error {
|
|
if instance.Status == StatusRemoved || instance.Status == StatusArchived {
|
|
return fmt.Errorf("%w: canvas is %s", ErrInvalidCanvasState, instance.Status)
|
|
}
|
|
if !expectedAuthority.IsZero() && expectedAuthority != instance.PublishAuthority() {
|
|
return ErrStaleCanvasPublish
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func loadPublicationState(ctx context.Context, store authoringInstanceStore, instanceID string) ([]plugininstances.Grant, []plugininstances.Release, error) {
|
|
grants, err := store.ListGrants(ctx, instanceID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
releases, err := store.ListReleases(ctx, instanceID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return grants, releases, nil
|
|
}
|
|
|
|
func (s *Service) resolveCreationAuthority(ctx context.Context, request PublishRequest, instance plugininstances.Instance, permissions PermissionSummary, grants []plugininstances.Grant, releases []plugininstances.Release) (CreationAuthority, []plugininstances.Grant, error) {
|
|
if request.ExpectedAuthority.IsZero() {
|
|
return CreationAuthority{}, nil, nil
|
|
}
|
|
candidate, err := s.repo.GetCreationAuthority(ctx, request.CanvasID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrCreationAuthorityNotFound) {
|
|
return CreationAuthority{}, nil, nil
|
|
}
|
|
return CreationAuthority{}, nil, err
|
|
}
|
|
if !creationAuthorityEligible(candidate, instance, request, grants, releases) {
|
|
return CreationAuthority{}, nil, nil
|
|
}
|
|
return candidate, grantsForManifest(permissions, candidate.OwnerUserID, creationAuthorityDataScope(candidate)), nil
|
|
}
|
|
|
|
func (s *Service) buildPublishedRelease(request PublishRequest, instanceID string, permissions PermissionSummary, activated bool) (plugininstances.Release, error) {
|
|
manifestJSON, err := json.Marshal(request.Package.Manifest)
|
|
if err != nil {
|
|
return plugininstances.Release{}, fmt.Errorf("marshal canvas manifest: %w", err)
|
|
}
|
|
permissionsJSON, err := json.Marshal(permissions)
|
|
if err != nil {
|
|
return plugininstances.Release{}, fmt.Errorf("marshal canvas permissions: %w", err)
|
|
}
|
|
validationStatus, validationError := plugininstances.ValidationPendingPermission, "permission_review_required"
|
|
if activated {
|
|
validationStatus, validationError = plugininstances.ValidationValid, ""
|
|
}
|
|
return plugininstances.Release{
|
|
ID: uuid.NewString(), PluginID: request.Package.Manifest.ID, InstanceID: instanceID,
|
|
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(),
|
|
}, nil
|
|
}
|
|
|
|
func persistPublishedRelease(ctx context.Context, store authoringInstanceStore, authorityStore creationAuthorityStore, canvasID, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority, expectedBaseReleaseID string, creationAuthority CreationAuthority, initialGrants []plugininstances.Grant, ownerUserID string, allowUnownedWorkspace bool, sessionID, taskID 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, authorityStore, canvasID, instanceID, release, activated, expectedAuthority, creationAuthority, initialGrants, ownerUserID, allowUnownedWorkspace, sessionID, taskID)
|
|
if err == nil && activated && creationAuthority.CanvasID != "" && creationAuthorityDataScope(creationAuthority) == ScopeWorkspace {
|
|
recordCanvasScopeTransition(canvasScopeFirstPublication, canvasScopeEnabled)
|
|
}
|
|
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, authorityStore creationAuthorityStore, canvasID, instanceID string, release plugininstances.Release, activated bool, expectedAuthority plugininstances.PublishAuthority, creationAuthority CreationAuthority, initialGrants []plugininstances.Grant, ownerUserID string, allowUnownedWorkspace bool, sessionID, taskID string) 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 creationAuthority.CanvasID != "" {
|
|
grantStore, ok := transactional.(initialGrantStore)
|
|
if !ok || authorityStore == nil || creationAuthority.CanvasID != canvasID {
|
|
return ErrStaleCanvasPublish
|
|
}
|
|
if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil {
|
|
return err
|
|
}
|
|
if creationAuthorityDataScope(creationAuthority) == ScopeWorkspace {
|
|
scopeStore, ok := transactional.(initialPublicationDataScopeStore)
|
|
if !ok {
|
|
return ErrStaleCanvasPublish
|
|
}
|
|
if err := scopeStore.SetInitialPublicationDataScopeTx(ctx, tx, expectedAuthority, release.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := grantStore.AddInitialGrantsTx(ctx, tx, instanceID, release.ID, ownerUserID, initialGrants); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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,
|
|
CurrentDataScope: canvas.DataScopeKind,
|
|
TargetDataScope: ScopeWorkspace,
|
|
Placement: manifest.WebAppPlacementWorkspace,
|
|
}, nil
|
|
}
|
|
|
|
// WorkspaceDataPreview describes the active release and exact permissions a
|
|
// workspace owner would enable for a retained task canvas.
|
|
func (s *Service) WorkspaceDataPreview(ctx context.Context, canvasID string) (*WorkspaceDataPreview, 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.Status != StatusActive || canvas.ActiveReleaseID == "" ||
|
|
canvas.ActiveReleaseStatus != ValidationValid || canvas.DataScopeKind != ScopeTask {
|
|
return nil, fmt.Errorf("%w: canvas must have an active task-only release", ErrInvalidCanvasState)
|
|
}
|
|
release, err := store.GetRelease(ctx, canvas.ActiveReleaseID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if release.InstanceID != canvas.PluginInstanceID || release.ValidationStatus != ValidationValid {
|
|
return nil, plugininstances.ErrInvalidRelease
|
|
}
|
|
m, err := manifestFromRelease(release)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &WorkspaceDataPreview{
|
|
Canvas: canvas,
|
|
Permissions: ManifestPermissions(m),
|
|
ActiveReleaseID: release.ID,
|
|
PermissionDigest: PermissionDigest(release),
|
|
GrantGeneration: canvas.GrantGeneration,
|
|
CurrentDataScopeKind: canvas.DataScopeKind,
|
|
TargetDataScopeKind: ScopeWorkspace,
|
|
}, nil
|
|
}
|
|
|
|
// EnableWorkspaceDataReviewed changes only data scope and declared grants.
|
|
// Placement, task ownership, and the active package remain unchanged.
|
|
func (s *Service) EnableWorkspaceDataReviewed(ctx context.Context, canvasID, userID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) (*Canvas, error) {
|
|
if err := s.ready(); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(userID) == "" {
|
|
return nil, fmt.Errorf("%w: approving user is required", ErrInvalidCanvas)
|
|
}
|
|
preview, err := s.WorkspaceDataPreview(ctx, canvasID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
grants := grantsForManifest(preview.Permissions, userID, ScopeWorkspace)
|
|
transactional, ok := s.instances.(transactionalAuthoringStore)
|
|
if !ok {
|
|
return nil, ErrStaleWorkspaceDataReview
|
|
}
|
|
reviewed, ok := s.instances.(reviewedWorkspaceDataStore)
|
|
if !ok {
|
|
return nil, ErrStaleWorkspaceDataReview
|
|
}
|
|
if err := transactional.WithTransaction(ctx, func(tx *sqlx.Tx) error {
|
|
return reviewed.EnableWorkspaceDataReviewedTx(ctx, tx, preview.Canvas.PluginInstanceID, preview.Canvas.WorkspaceID, userID, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration)
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
recordCanvasScopeTransition(canvasScopeReviewedUpgrade, canvasScopeEnabled)
|
|
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(EventWorkspaceDataEnabled, *updated))
|
|
return updated, 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
|
|
}
|
|
result := canvasScopePreserved
|
|
if preview.CurrentDataScope != ScopeWorkspace && updated.DataScopeKind == ScopeWorkspace {
|
|
result = canvasScopeExpanded
|
|
}
|
|
recordCanvasScopeTransition(canvasScopePromotion, result)
|
|
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.DataScopeKind)); 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.EffectiveDataScopeKind(), 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 creationAuthorityEligible(authority CreationAuthority, instance plugininstances.Instance, request PublishRequest, grants []plugininstances.Grant, releases []plugininstances.Release) bool {
|
|
return creationAuthorityPolicyIsCurrent(authority) &&
|
|
creationAuthorityInstanceMatches(authority, instance, grants, releases) &&
|
|
creationAuthoritySourceMatches(authority, instance, request)
|
|
}
|
|
|
|
func validateLocalCanvasManifest(m *manifest.Manifest) error {
|
|
if !localCanvasUIIsValid(m) {
|
|
return invalidLocalCanvasManifest()
|
|
}
|
|
if !localCanvasIntegrationsAreValid(m) {
|
|
return invalidLocalCanvasManifest()
|
|
}
|
|
if !localCanvasCapabilitiesAreValid(m) {
|
|
return invalidLocalCanvasManifest()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func localCanvasUIIsValid(m *manifest.Manifest) bool {
|
|
return m.IsStaticWebAppOnly() && len(m.UI.Pages) == 0 && m.UI.Bundle == "" && len(m.UI.Styles) == 0 && len(m.UI.Keybindings) == 0
|
|
}
|
|
|
|
func localCanvasIntegrationsAreValid(m *manifest.Manifest) bool {
|
|
return len(m.Webhooks) == 0 && len(m.Actions) == 0 && len(m.RepositoryProviders) == 0 && len(m.ReferenceSources) == 0 && len(m.AuthProviders) == 0 && len(m.AgentTools) == 0
|
|
}
|
|
|
|
func localCanvasCapabilitiesAreValid(m *manifest.Manifest) bool {
|
|
return !m.Capabilities.Secrets && !m.Capabilities.AgentInvoke && !m.Capabilities.Auth && !m.Capabilities.UserState &&
|
|
supportedCanvasCapabilityValues(m.Capabilities.APIRead, permissionKindAPIRead) &&
|
|
supportedCanvasCapabilityValues(m.Capabilities.APIWrite, permissionKindAPIWrite) &&
|
|
supportedCanvasCapabilityValues(m.Capabilities.Events, permissionKindEvents)
|
|
}
|
|
|
|
func supportedCanvasCapabilityValues(values []string, kind string) bool {
|
|
for _, value := range values {
|
|
if !supportedCanvasCapability(kind, value) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func supportedCanvasCapability(kind, value string) bool {
|
|
switch kind {
|
|
case permissionKindAPIRead:
|
|
return value == canvasCapabilityTasks || value == canvasCapabilityWorkflows
|
|
case permissionKindAPIWrite:
|
|
return value == canvasCapabilityTasks || value == canvasCapabilityMessages
|
|
case permissionKindEvents:
|
|
return value == canvasCapabilityTaskUpdate
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func invalidLocalCanvasManifest() error {
|
|
return fmt.Errorf("%w: canvas packages support only static web applications and task-scoped data permissions", ErrInvalidCanvas)
|
|
}
|
|
|
|
func creationAuthorityPolicyIsCurrent(authority CreationAuthority) bool {
|
|
return creationAuthorityPolicyVersionSupported(authority.PolicyVersion) && authority.ConsumedAt.IsZero() && authority.OwnerUserID != "" && authority.CreatingSessionID != "" && authority.TaskID != ""
|
|
}
|
|
|
|
func creationAuthorityInstanceMatches(authority CreationAuthority, instance plugininstances.Instance, grants []plugininstances.Grant, releases []plugininstances.Release) bool {
|
|
return instance.SourceKind == plugininstances.SourceLocalCanvas && instance.ScopeKind == ScopeTask && instance.EffectiveDataScopeKind() == ScopeTask && instance.Status == StatusPending && instance.ActiveReleaseID == "" && instance.GrantGeneration == 0 && instance.TaskID == authority.TaskID && len(grants) == 0 && len(releases) == 0
|
|
}
|
|
|
|
func creationAuthoritySourceMatches(authority CreationAuthority, instance plugininstances.Instance, request PublishRequest) bool {
|
|
return request.SourceActorKind == "agent" && request.SourceUserID == authority.OwnerUserID && request.SourceTaskID == instance.TaskID && request.SourceSessionID == authority.CreatingSessionID
|
|
}
|
|
|
|
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
|
|
}
|