Files
kandev/apps/backend/internal/canvas/authoring.go
T
2026-09-23 20:51:55 +01:00

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
}