feat: preview task canvases with workspace data (#3887)

This commit is contained in:
Carlos Florêncio
2026-09-23 20:51:55 +01:00
committed by GitHub
parent 9874ac4cf5
commit 4bebf7c511
79 changed files with 3369 additions and 282 deletions
+9 -9
View File
@@ -97,13 +97,14 @@ apps/backend/
│ └── worktree/ # Git worktree management for workspace isolation
```
Canvas creation authority is recorded only by the trusted task authoring
adapter. It binds the workspace owner, creating session, task scope, and
policy version to a new draft. The first valid static web-app release may
consume that single-use authority for exact task-scoped grants in the same
transaction as release insertion and activation. Existing drafts, imports,
later permission increases, and revoked grants remain on the human review
path. Never use source metadata or a manifest trust field as authority.
Canvas creation authority comes only from the trusted task adapter and binds
the owner, session, task, and policy version. Its first valid static release
may consume that authority for exact workspace-ceiling grants in the activation
transaction. Existing drafts, imports, later permission increases, and
revoked grants still require human review; source metadata and manifests never
grant authority.
Canvas scopes expose `canvas_data_scope_transition_total` with fixed transition
and result labels. Lifecycle events carry IDs; metrics never label task data.
## Key Concepts
@@ -293,8 +294,7 @@ Enforced by `apps/backend/.golangci.yml` (errors on new code only):
When a PR fixup touches backend code, run `golangci-lint run ./... --new-from-rev="<base-sha>" --timeout=5m` from `apps/backend` with the PR base SHA before pushing; CI enforces changed-file complexity thresholds.
- `internal/launcher/` — native launcher owning every entrypoint (`dev`, `start`, `run`, `service`); `dev` runs `make -C apps/backend dev` with Vite as a supervised child, state under `<repoRoot>/.kandev-dev/`. The root `make dev` prebuilds only the copied launcher; the backend dev target builds the native agentctl and a linux/amd64 helper when the host is not Linux/amd64 (`docs/plans/go-dev-launcher/`).
- `internal/agentctl/AGENTS.md` — agentctl server route groups, adapter model, ACP protocol
- `internal/agentctl/AGENTS.md` — server routes, adapters, ACP; `cmd/mock-agent/AGENTS.md` — E2E scenario patterns and rebuild requirements
- `internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping
- `internal/integrations/AGENTS.md` — playbook for adding a new third-party integration (Jira/Linear pattern)
- `docs/i18n.md` ("Backend") — `internal/i18n` covers only what Go renders straight to a browser: the SPA-unavailable error pages and the shared-task artifacts (`share.html`, gist README and description). Both are complete. Everything else stays English by design; for new user-facing output prefer a stable error code the frontend translates. Use `i18n.Tf` for anything carrying a value — never `fmt.Sprintf` a translated string, and never build a plural in Go. A locale for output that outlives the request is resolved once at write time and threaded as an argument, not a context value (ADR `2026-08-01-share-artifact-locale.md`).
- `cmd/mock-agent/AGENTS.md` — predefined `/e2e:<name>` scenarios vs inline `e2e:...` scripts, recipe for adding a scenario, and the rebuild-before-e2e requirement
+4 -2
View File
@@ -16,8 +16,10 @@ canvas identity and whether its release is active, awaits permission review,
or was unsuccessful.
If publication is unsuccessful, report the failure and do not claim that the
canvas is published. If workspace access requires promotion, explain the user
action that is still required.
canvas is published. A new owner-authorized task canvas can use its declared
supported permissions across the current workspace before promotion. Promotion
adds workspace navigation. Do not claim that promotion is required to preview
workspace task data.
A local build alone does not publish a canvas inside Kandev. If the tools remain
unavailable, report the limitation instead of claiming that workspace files
@@ -351,12 +351,7 @@ func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcph
return nil, err
}
defer s.endPublish(request.Agent.SessionID, item.ID)
expectedAuthority := instances.PublishAuthority{
InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind,
WorkspaceID: item.WorkspaceID, TaskID: item.TaskID,
Status: item.Status, ActiveReleaseID: item.ActiveReleaseID,
GrantGeneration: item.GrantGeneration,
}
expectedAuthority := canvasPublishAuthority(item)
expectedBaseReleaseID := ""
if editSession {
expectedBaseReleaseID = target.ReleaseID
@@ -364,6 +359,15 @@ func (s *canvasAuthoringService) PublishCanvas(ctx context.Context, request mcph
return s.publishCanvasSource(ctx, execution, task, item, request, expectedAuthority, expectedBaseReleaseID)
}
func canvasPublishAuthority(item *canvas.Canvas) instances.PublishAuthority {
return instances.PublishAuthority{
InstanceID: item.PluginInstanceID, ScopeKind: item.ScopeKind,
DataScopeKind: item.DataScopeKind, WorkspaceID: item.WorkspaceID,
TaskID: item.TaskID, Status: item.Status,
ActiveReleaseID: item.ActiveReleaseID, GrantGeneration: item.GrantGeneration,
}
}
func validateCanvasPublishSource(item *canvas.Canvas, request mcphandlers.CanvasPublishRequest) error {
if request.SourcePath != canvasSourceRoot(item.ID) {
return canvasOperationError("invalid_source_root", "source_path must be the assigned canvas directory", nil)
@@ -0,0 +1,28 @@
package backendapp
import (
"testing"
"github.com/kandev/kandev/internal/canvas"
"github.com/kandev/kandev/internal/plugins/instances"
)
func TestCanvasPublishAuthorityCapturesPlacementAndDataScope(t *testing.T) {
item := &canvas.Canvas{
PluginInstanceID: "instance-1",
ScopeKind: canvas.ScopeTask,
DataScopeKind: instances.ScopeWorkspace,
WorkspaceID: "workspace-1",
TaskID: "task-1",
Status: canvas.StatusActive,
GrantGeneration: 4,
}
authority := canvasPublishAuthority(item)
if authority.ScopeKind != canvas.ScopeTask || authority.DataScopeKind != instances.ScopeWorkspace {
t.Fatalf("publish authority scopes = placement %q, data %q; want task placement and workspace data", authority.ScopeKind, authority.DataScopeKind)
}
if authority.InstanceID != item.PluginInstanceID || authority.WorkspaceID != item.WorkspaceID || authority.TaskID != item.TaskID || authority.Status != item.Status || authority.GrantGeneration != item.GrantGeneration {
t.Fatalf("publish authority = %+v, missing current canvas state", authority)
}
}
+111 -17
View File
@@ -23,6 +23,7 @@ import (
"github.com/kandev/kandev/internal/plugins/webapp"
"github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
userstore "github.com/kandev/kandev/internal/user/store"
"github.com/kandev/kandev/internal/worktree/copyfiles"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
@@ -38,6 +39,11 @@ type canvasHTTPHandler struct {
distribution *canvasservice.DistributionService
}
type canvasWorkspaceOwnerAccess interface {
AuthorizeWorkspaceAccess(context.Context, string) error
GetWorkspace(context.Context, string) (*models.Workspace, error)
}
type canvasEditTaskStore interface {
AuthorizeWorkspaceAccess(context.Context, string) error
GetWorkspace(context.Context, string) (*models.Workspace, error)
@@ -370,6 +376,8 @@ func registerCanvasRoutes(p routeParams) {
p.router.GET("/api/v1/canvases/:canvasID/releases", h.releases)
p.router.GET("/api/v1/canvases/:canvasID/promotion-preview", h.promotionPreview)
p.router.POST("/api/v1/canvases/:canvasID/promotion", h.promote)
p.router.GET("/api/v1/canvases/:canvasID/workspace-data-preview", h.workspaceDataPreview)
p.router.POST("/api/v1/canvases/:canvasID/workspace-data", h.enableWorkspaceData)
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/approve", h.approveRelease)
p.router.POST("/api/v1/canvases/:canvasID/releases/:releaseID/reject", h.rejectRelease)
p.router.POST("/api/v1/canvases/:canvasID/rollback", h.rollback)
@@ -390,6 +398,7 @@ type canvasHTTPResponse struct {
TaskID string `json:"task_id,omitempty"`
OriginTaskID string `json:"origin_task_id,omitempty"`
ScopeKind string `json:"scope_kind"`
DataScopeKind string `json:"data_scope_kind"`
Title string `json:"title"`
CreatedBySessionID string `json:"created_by_session_id,omitempty"`
PromotedByUserID string `json:"promoted_by_user_id,omitempty"`
@@ -442,6 +451,7 @@ func canvasResponse(value canvasservice.Canvas) canvasHTTPResponse {
TaskID: value.TaskID,
OriginTaskID: value.OriginTaskID,
ScopeKind: value.ScopeKind,
DataScopeKind: value.DataScopeKind,
Title: value.Title,
CreatedBySessionID: value.CreatedBySessionID,
PromotedByUserID: value.PromotedByUserID,
@@ -633,23 +643,25 @@ func (h *canvasHTTPHandler) promotionPreview(c *gin.Context) {
preview.SourceSessionID,
)
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
"canvas_id": preview.Canvas.ID,
"title": preview.Canvas.Title,
"origin_task_id": preview.Canvas.OriginTaskID,
"active_release": releaseResponseFromCanvas(preview.Canvas),
"source_actor_kind": preview.SourceActorKind,
"source_user_id": preview.SourceUserID,
"source_task_id": preview.SourceTaskID,
"source_task_title": sourceLabels.TaskTitle,
"source_session_id": preview.SourceSessionID,
"source_session_name": sourceLabels.SessionName,
"permissions": preview.Permissions,
"active_release_id": preview.ActiveReleaseID,
"permission_digest": preview.PermissionDigest,
"grant_generation": preview.GrantGeneration,
"current_scope": preview.CurrentScope,
"target_scope": preview.TargetScope,
"placement": preview.Placement,
"canvas_id": preview.Canvas.ID,
"title": preview.Canvas.Title,
"origin_task_id": preview.Canvas.OriginTaskID,
"active_release": releaseResponseFromCanvas(preview.Canvas),
"source_actor_kind": preview.SourceActorKind,
"source_user_id": preview.SourceUserID,
"source_task_id": preview.SourceTaskID,
"source_task_title": sourceLabels.TaskTitle,
"source_session_id": preview.SourceSessionID,
"source_session_name": sourceLabels.SessionName,
"permissions": preview.Permissions,
"active_release_id": preview.ActiveReleaseID,
"permission_digest": preview.PermissionDigest,
"grant_generation": preview.GrantGeneration,
"current_scope": preview.CurrentScope,
"target_scope": preview.TargetScope,
"current_data_scope_kind": preview.CurrentDataScope,
"target_data_scope_kind": preview.TargetDataScope,
"placement": preview.Placement,
})
}
@@ -688,6 +700,56 @@ func (h *canvasHTTPHandler) promote(c *gin.Context) {
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
}
func (h *canvasHTTPHandler) workspaceDataPreview(c *gin.Context) {
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
if err != nil {
h.writeError(c, err)
return
}
if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) {
return
}
preview, err := h.canvases.WorkspaceDataPreview(c.Request.Context(), item.ID)
if err != nil {
h.writeError(c, err)
return
}
writeCanvasJSON(c, http.StatusOK, map[string]interface{}{
"canvas": canvasResponse(*preview.Canvas),
"active_release_id": preview.ActiveReleaseID,
"permission_digest": preview.PermissionDigest,
"grant_generation": preview.GrantGeneration,
"current_data_scope_kind": preview.CurrentDataScopeKind,
"target_data_scope_kind": preview.TargetDataScopeKind,
"permissions": preview.Permissions,
})
}
func (h *canvasHTTPHandler) enableWorkspaceData(c *gin.Context) {
item, err := h.canvases.Get(c.Request.Context(), c.Param("canvasID"))
if err != nil {
h.writeError(c, err)
return
}
if item == nil || !h.authorizeWorkspaceOwner(c, item.WorkspaceID) {
return
}
var request canvasPromotionRequest
if c.Request.Body == nil || c.ShouldBindJSON(&request) != nil || strings.TrimSpace(request.ExpectedReleaseID) == "" || strings.TrimSpace(request.ExpectedPermissionDigest) == "" || request.ExpectedGrantGeneration == nil || *request.ExpectedGrantGeneration < 0 {
writeCanvasError(c, http.StatusBadRequest, "workspace_data_review_required", nil)
return
}
updated, err := h.canvases.EnableWorkspaceDataReviewed(
c.Request.Context(), item.ID, canvasRuntimeUser(c), request.ExpectedReleaseID,
request.ExpectedPermissionDigest, *request.ExpectedGrantGeneration,
)
if err != nil {
h.writeError(c, err)
return
}
writeCanvasJSON(c, http.StatusOK, canvasResponse(*updated))
}
func (h *canvasHTTPHandler) approveRelease(c *gin.Context) {
h.releaseMutation(c, func(ctx context.Context, canvasID, releaseID, userID string) (*canvasservice.Canvas, error) {
return h.canvases.ApproveRelease(ctx, canvasID, releaseID, userID)
@@ -886,6 +948,38 @@ func (h *canvasHTTPHandler) authorizeWorkspaceManage(c *gin.Context, workspaceID
return true
}
func (h *canvasHTTPHandler) authorizeWorkspaceOwner(c *gin.Context, workspaceID string) bool {
owner, err := checkCanvasWorkspaceOwner(c.Request.Context(), h.tasks, workspaceID, canvasRuntimeUser(c))
if err != nil {
writeCanvasError(c, http.StatusNotFound, "canvas_not_found", nil)
return false
}
if !owner {
writeCanvasError(c, http.StatusForbidden, "workspace_owner_required", nil)
return false
}
return true
}
func checkCanvasWorkspaceOwner(ctx context.Context, access canvasWorkspaceOwnerAccess, workspaceID, userID string) (bool, error) {
if access == nil {
return false, errors.New("canvas workspace access is unavailable")
}
if err := access.AuthorizeWorkspaceAccess(ctx, workspaceID); err != nil {
return false, err
}
workspace, err := access.GetWorkspace(ctx, workspaceID)
if err != nil {
return false, err
}
if workspace == nil {
return false, errors.New("canvas workspace was not found")
}
actorID := strings.TrimSpace(userID)
ownerID := strings.TrimSpace(workspace.OwnerID)
return actorID != "" && (ownerID == actorID || (ownerID == "" && actorID == userstore.DefaultUserID)), nil
}
func canvasRouteParam(c *gin.Context, names ...string) string {
for _, name := range names {
if value := c.Param(name); value != "" {
@@ -0,0 +1,70 @@
package backendapp
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/task/models"
userstore "github.com/kandev/kandev/internal/user/store"
)
type canvasWorkspaceOwnerAccessStub struct {
workspace *models.Workspace
accessErr error
workspaceErr error
workspaceRead bool
}
func (s *canvasWorkspaceOwnerAccessStub) AuthorizeWorkspaceAccess(context.Context, string) error {
return s.accessErr
}
func (s *canvasWorkspaceOwnerAccessStub) GetWorkspace(context.Context, string) (*models.Workspace, error) {
s.workspaceRead = true
return s.workspace, s.workspaceErr
}
func TestCheckCanvasWorkspaceOwnerRequiresCurrentWorkspaceOwner(t *testing.T) {
tests := []struct {
name string
userID string
workspace *models.Workspace
wantOwner bool
}{
{name: "owner", userID: "owner-1", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}, wantOwner: true},
{name: "workspace member", userID: "member-1", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}},
{name: "empty user", workspace: &models.Workspace{ID: "workspace-1", OwnerID: "owner-1"}},
{name: "auth-disabled default user owns an unowned workspace", userID: userstore.DefaultUserID, workspace: &models.Workspace{ID: "workspace-1"}, wantOwner: true},
{name: "ordinary user cannot own an unowned workspace", userID: "member-1", workspace: &models.Workspace{ID: "workspace-1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
access := &canvasWorkspaceOwnerAccessStub{workspace: tt.workspace}
owner, err := checkCanvasWorkspaceOwner(context.Background(), access, "workspace-1", tt.userID)
if err != nil {
t.Fatalf("checkCanvasWorkspaceOwner() error = %v", err)
}
if owner != tt.wantOwner {
t.Fatalf("owner = %t, want %t", owner, tt.wantOwner)
}
if !access.workspaceRead {
t.Fatal("workspace owner was not checked")
}
})
}
}
func TestCheckCanvasWorkspaceOwnerDoesNotReadWorkspaceAfterAccessDenial(t *testing.T) {
access := &canvasWorkspaceOwnerAccessStub{accessErr: errors.New("workspace access denied")}
owner, err := checkCanvasWorkspaceOwner(context.Background(), access, "workspace-1", "owner-1")
if err == nil {
t.Fatal("checkCanvasWorkspaceOwner() error = nil, want access denial")
}
if owner {
t.Fatal("owner = true after workspace access denial")
}
if access.workspaceRead {
t.Fatal("workspace details were read after access denial")
}
}
@@ -39,12 +39,13 @@ func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice.
WebAppKey: app.Key,
Placement: placement,
ScopeKind: instance.ScopeKind,
DataScopeKind: instance.EffectiveDataScopeKind(),
WorkspaceID: instance.WorkspaceID,
TaskID: instance.TaskID,
SessionID: instance.SessionID,
RepositoryID: instance.RepositoryID,
GrantGeneration: instance.GrantGeneration,
Permissions: runtimeGrantedPermissions(appManifest, instance.ScopeKind, grants),
Permissions: runtimeGrantedPermissions(appManifest, instance.EffectiveDataScopeKind(), grants),
Artifact: webapp.Artifact{
Digest: release.PackageDigest,
RelativePath: release.ArtifactPath,
@@ -52,7 +53,7 @@ func (h *canvasHTTPHandler) runtimeBinding(c *gin.Context, canvas canvasservice.
Available: true,
},
Entry: app.Entry,
NetworkOrigins: runtimeNetworkOrigins(app, instance.ScopeKind, grants),
NetworkOrigins: runtimeNetworkOrigins(app, instance.EffectiveDataScopeKind(), grants),
}
runtime := h.plugins.WebRuntime()
if runtime == nil {
@@ -141,6 +142,10 @@ func (h *canvasHTTPHandler) writeError(c *gin.Context, err error) {
status, code = http.StatusBadRequest, canvasErrorCodeInvalid
case errors.Is(err, canvasservice.ErrStalePromotionReview):
status, code = http.StatusConflict, "promotion_review_stale"
case errors.Is(err, canvasservice.ErrStaleWorkspaceDataReview):
status, code = http.StatusConflict, "workspace_data_review_stale"
case errors.Is(err, instances.ErrWorkspaceOwnerRequired):
status, code = http.StatusForbidden, "workspace_owner_required"
case errors.Is(err, canvasservice.ErrStaleCanvasEdit):
status, code = http.StatusConflict, "canvas_edit_stale"
case errors.Is(err, canvasservice.ErrInvalidCanvas), errors.Is(err, canvasservice.ErrInvalidCanvasState):
+130 -10
View File
@@ -74,9 +74,23 @@ type PromotionPreview struct {
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.
@@ -106,12 +120,20 @@ 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, []plugininstances.Grant) error
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 {
@@ -294,7 +316,7 @@ func (s *Service) preparePublishedRelease(ctx context.Context, store authoringIn
if err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, CreationAuthority{}, nil, err
}
activated := permissionsFit(permissions, instance.ScopeKind, grants)
activated := permissionsFit(permissions, instance.EffectiveDataScopeKind(), grants)
if creationAuthority.CanvasID != "" {
activated = true
}
@@ -326,7 +348,7 @@ func (s *Service) preparePublishedReleaseForInstance(ctx context.Context, store
if err != nil {
return plugininstances.Instance{}, plugininstances.Release{}, false, err
}
activated := permissionsFit(permissions, instance.ScopeKind, grants)
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
@@ -383,7 +405,7 @@ func (s *Service) resolveCreationAuthority(ctx context.Context, request PublishR
if !creationAuthorityEligible(candidate, instance, request, grants, releases) {
return CreationAuthority{}, nil, nil
}
return candidate, grantsForManifest(permissions, candidate.OwnerUserID, ScopeTask), 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) {
@@ -422,6 +444,9 @@ func persistPublishedRelease(ctx context.Context, store authoringInstanceStore,
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 != "" {
@@ -460,10 +485,19 @@ func persistAuthorityRelease(ctx context.Context, transactional transactionalAut
if !ok || authorityStore == nil || creationAuthority.CanvasID != canvasID {
return ErrStaleCanvasPublish
}
if err := grantStore.AddInitialGrantsTx(ctx, tx, instanceID, ownerUserID, initialGrants); err != nil {
if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil {
return err
}
if err := authorityStore.ConsumeCreationAuthorityTx(ctx, tx, creationAuthority, ownerUserID, sessionID, taskID, allowUnownedWorkspace); err != nil {
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
}
}
@@ -568,10 +602,91 @@ func (s *Service) PromotionPreview(ctx context.Context, canvasID string) (*Promo
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.
@@ -605,6 +720,11 @@ func (s *Service) PromoteCanvasReviewed(ctx context.Context, canvasID, userID, e
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()
@@ -675,7 +795,7 @@ func (s *Service) ApproveRelease(ctx context.Context, canvasID, releaseID, userI
if err != nil {
return nil, err
}
if err := store.ApproveRelease(ctx, canvas.PluginInstanceID, releaseID, userID, grantsForManifest(ManifestPermissions(m), userID, canvas.ScopeKind)); err != nil {
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 {
@@ -768,7 +888,7 @@ func (s *Service) RollbackRelease(ctx context.Context, canvasID, releaseID strin
if err != nil {
return nil, err
}
if !permissionsFit(ManifestPermissions(m), instance.ScopeKind, grants) {
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 {
@@ -1012,11 +1132,11 @@ func invalidLocalCanvasManifest() error {
}
func creationAuthorityPolicyIsCurrent(authority CreationAuthority) bool {
return authority.PolicyVersion == CreationAuthorityPolicyVersion && authority.ConsumedAt.IsZero() && authority.OwnerUserID != "" && authority.CreatingSessionID != "" && authority.TaskID != ""
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.Status == StatusPending && instance.ActiveReleaseID == "" && instance.GrantGeneration == 0 && instance.TaskID == authority.TaskID && len(grants) == 0 && len(releases) == 0
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 {
+104 -7
View File
@@ -85,6 +85,9 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
if err != nil {
t.Fatalf("get instance: %v", err)
}
if instance.DataScopeKind != "" || instance.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("new owner-authorized canvas scope = explicit %q/effective %q, want task until its first publication is consumed", instance.DataScopeKind, instance.EffectiveDataScopeKind())
}
pkg := testCanvasPackage("owner-first", []string{"tasks"})
pkg.Manifest.Capabilities.APIWrite = []string{"messages"}
pkg.Manifest.Capabilities.Events = []string{"task.updated"}
@@ -114,6 +117,9 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
if updated.ActiveReleaseID != result.Release.ID || updated.PluginID != result.Release.PluginID {
t.Fatalf("updated instance = %+v, want active release and package identity", updated)
}
if updated.ScopeKind != ScopeTask || updated.EffectiveDataScopeKind() != ScopeWorkspace {
t.Fatalf("updated scopes = placement %q, effective data %q, want task and workspace", updated.ScopeKind, updated.EffectiveDataScopeKind())
}
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("list initial grants: %v", err)
@@ -136,8 +142,8 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
if grant.PermissionKind == "network" {
key += grant.NetworkOrigin
}
if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeTask || grant.ApprovedBy != "owner-1" {
t.Fatalf("initial grant = %+v, want task-scoped owner grant", grant)
if _, ok := wantGrants[key]; !ok || grant.ScopeCeiling != ScopeWorkspace || grant.ApprovedBy != "owner-1" {
t.Fatalf("initial grant = %+v, want workspace-scoped owner grant", grant)
}
wantGrants[key] = true
}
@@ -156,6 +162,73 @@ func TestCanvasCreationAuthorityFirstPublish(t *testing.T) {
}
}
func TestCanvasCreationAuthorityVersionOneRetainsTaskDataCeiling(t *testing.T) {
service, instanceStore, pool := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy task authority",
CreatedBySessionID: "session-1", OwnerUserID: "owner-1",
})
if _, err := pool.Writer().Exec(`UPDATE canvas_creation_authority SET policy_version = 1 WHERE canvas_id = ?`, created.ID); err != nil {
t.Fatalf("set legacy authority version: %v", err)
}
instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get instance: %v", err)
}
result, err := service.PublishPackage(context.Background(), PublishRequest{
CanvasID: created.ID, Package: testCanvasPackage("legacy-task-authority", []string{"tasks"}),
Artifact: webapp.Artifact{Digest: "legacy-task-authority", RelativePath: "releases/legacy-task-authority", Bytes: 1},
ExpectedAuthority: instance.PublishAuthority(), SourceActorKind: "agent", SourceUserID: "owner-1",
SourceTaskID: "task-1", SourceSessionID: "session-1",
})
if err != nil {
t.Fatalf("publish with legacy authority: %v", err)
}
if !result.Activated {
t.Fatalf("legacy authority result = %+v, want active task-scoped first release", result)
}
updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get published instance: %v", err)
}
if updated.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("legacy authority effective data scope = %q, want task", updated.EffectiveDataScopeKind())
}
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("list legacy initial grants: %v", err)
}
if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask {
t.Fatalf("legacy authority grants = %+v, want one task-scoped task-read grant", grants)
}
}
func TestPublishCanvasAuthorityNormalizesLegacyNullDataScope(t *testing.T) {
service, instanceStore, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy data scope"})
canvas, err := service.Get(context.Background(), created.ID)
if err != nil {
t.Fatalf("get canvas: %v", err)
}
instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get instance: %v", err)
}
expected := instance.PublishAuthority()
expected.DataScopeKind = canvas.DataScopeKind
result, err := service.PublishPackage(context.Background(), PublishRequest{
CanvasID: created.ID, Package: testCanvasPackage("legacy-null-data-scope", []string{"tasks"}),
Artifact: webapp.Artifact{Digest: "legacy-null-data-scope", RelativePath: "releases/legacy-null-data-scope", Bytes: 1},
ExpectedAuthority: expected, SourceActorKind: "agent",
})
if err != nil {
t.Fatalf("publish with projected legacy data scope: %v", err)
}
if result.Activated || !result.PermissionRequired {
t.Fatalf("legacy task-scope publication = %+v, want task permission review", result)
}
}
func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) {
service, instanceStore, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{
@@ -185,12 +258,26 @@ func TestCanvasCreationAuthorityRejectsMismatchedSource(t *testing.T) {
if result.Activated || !result.PermissionRequired {
t.Fatalf("mismatched owner result = %+v, want manual review", result)
}
updated, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get mismatched-owner instance: %v", err)
}
if updated.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("mismatched-owner effective data scope = %q, want task", updated.EffectiveDataScopeKind())
}
approved, err := service.ApproveRelease(context.Background(), created.ID, result.Release.ID, "member-1")
if err != nil {
t.Fatalf("member approves task-scoped fallback release: %v", err)
}
if approved.DataScopeKind != ScopeTask {
t.Fatalf("member approval widened effective data scope to %q, want task", approved.DataScopeKind)
}
grants, err := instanceStore.ListGrants(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("list mismatched-owner grants: %v", err)
t.Fatalf("list fallback grants: %v", err)
}
if len(grants) != 0 {
t.Fatalf("mismatched-owner grants = %+v, want none", grants)
if len(grants) != 1 || grants[0].ScopeCeiling != ScopeTask {
t.Fatalf("member approval grants = %+v, want one task-scoped grant", grants)
}
authority, err := service.repo.GetCreationAuthority(context.Background(), created.ID)
if err != nil {
@@ -252,12 +339,22 @@ func TestCanvasCreationAuthorityDoesNotApproveLaterIncrease(t *testing.T) {
if second.Activated || !second.PermissionRequired {
t.Fatalf("later owner release = %+v, want pending review", second)
}
if _, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "member-1"); !errors.Is(err, plugininstances.ErrWorkspaceOwnerRequired) {
t.Fatalf("non-owner approval of workspace-data increase = %v, want ErrWorkspaceOwnerRequired", err)
}
approved, err := service.ApproveRelease(context.Background(), created.ID, second.Release.ID, "owner-1")
if err != nil {
t.Fatalf("owner approves workspace-data increase: %v", err)
}
if approved.ActiveReleaseID != second.Release.ID {
t.Fatalf("active release after owner approval = %q, want %q", approved.ActiveReleaseID, second.Release.ID)
}
current, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get current instance: %v", err)
}
if current.ActiveReleaseID != first.Release.ID {
t.Fatalf("active release after later increase = %q, want %q", current.ActiveReleaseID, first.Release.ID)
if current.ActiveReleaseID != second.Release.ID {
t.Fatalf("active release after owner-approved increase = %q, want %q", current.ActiveReleaseID, second.Release.ID)
}
}
@@ -82,7 +82,7 @@ func effectiveGrantProjection(instance plugininstances.Instance, summary Permiss
declared := permissionKeys(summary)
result := make([]GrantProjection, 0, len(grants))
for _, grant := range grants {
if !grantScopeCovers(grant.ScopeCeiling, instance.ScopeKind) {
if !grantScopeCovers(grant.ScopeCeiling, instance.EffectiveDataScopeKind()) {
continue
}
permission := grant.PermissionKind + ":" + grant.Resource
+2 -2
View File
@@ -304,7 +304,7 @@ WHERE a.canvas_id = ?`,
// while rechecking its owner, session, task, policy, task scope, and current
// workspace ownership.
func (r *Repository) ConsumeCreationAuthorityTx(ctx context.Context, tx *sqlx.Tx, authority CreationAuthority, ownerUserID, sessionID, taskID string, allowUnownedWorkspace bool) error {
if authority.CanvasID == "" || ownerUserID == "" || sessionID == "" || taskID == "" {
if authority.CanvasID == "" || !creationAuthorityPolicyVersionSupported(authority.PolicyVersion) || ownerUserID == "" || sessionID == "" || taskID == "" {
return ErrStaleCanvasPublish
}
now := time.Now().UTC().Format(time.RFC3339Nano)
@@ -321,7 +321,7 @@ WHERE canvas_creation_authority.canvas_id = ? AND owner_user_id = ? AND creating
AND m.task_id = ?
AND (w.owner_id = ? OR (? AND COALESCE(w.owner_id, '') = ''))
)`,
), now, authority.CanvasID, ownerUserID, sessionID, CreationAuthorityPolicyVersion, taskID, ownerUserID, allowUnownedWorkspace)
), now, authority.CanvasID, ownerUserID, sessionID, authority.PolicyVersion, taskID, ownerUserID, allowUnownedWorkspace)
if err != nil {
return err
}
@@ -0,0 +1,24 @@
package canvas
import "expvar"
var canvasDataScopeTransitions = expvar.NewMap("canvas_data_scope_transition_total")
type canvasScopeTransition string
type canvasScopeTransitionResult string
const (
canvasScopeFirstPublication canvasScopeTransition = "first_publication"
canvasScopeReviewedUpgrade canvasScopeTransition = "reviewed_upgrade"
canvasScopePromotion canvasScopeTransition = "promotion"
)
const (
canvasScopeEnabled canvasScopeTransitionResult = "data_scope_enabled"
canvasScopeExpanded canvasScopeTransitionResult = "data_scope_expanded"
canvasScopePreserved canvasScopeTransitionResult = "data_scope_preserved"
)
func recordCanvasScopeTransition(transition canvasScopeTransition, result canvasScopeTransitionResult) {
canvasDataScopeTransitions.Add("transition="+string(transition)+";result="+string(result), 1)
}
@@ -0,0 +1,42 @@
package canvas
import (
"expvar"
"strconv"
"testing"
)
func canvasScopeMetricCount(t *testing.T, label string) int64 {
t.Helper()
value := expvar.Get("canvas_data_scope_transition_total")
if value == nil {
t.Fatal("canvas data-scope transition counter is not registered")
}
metrics, ok := value.(*expvar.Map)
if !ok {
t.Fatalf("canvas data-scope transition metric has type %T, want *expvar.Map", value)
}
var count int64
metrics.Do(func(keyValue expvar.KeyValue) {
if keyValue.Key != label {
return
}
parsed, err := strconv.ParseInt(keyValue.Value.String(), 10, 64)
if err != nil {
t.Errorf("canvas data-scope metric %q value = %q: %v", label, keyValue.Value.String(), err)
return
}
count = parsed
})
return count
}
func TestRecordCanvasScopeTransitionUsesFixedLabels(t *testing.T) {
label := "transition=reviewed_upgrade;result=data_scope_enabled"
before := canvasScopeMetricCount(t, label)
recordCanvasScopeTransition(canvasScopeReviewedUpgrade, canvasScopeEnabled)
after := canvasScopeMetricCount(t, label)
if after-before != 1 {
t.Fatalf("transition counter delta = %d, want 1", after-before)
}
}
+4 -2
View File
@@ -737,7 +737,7 @@ func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, inst
return Canvas{}, err
}
if instance.ActiveReleaseID == "" {
return s.addPendingRelease(ctx, &canvas, instance.ID, instance.ScopeKind, grants)
return s.addPendingRelease(ctx, &canvas, instance.ID, instance.EffectiveDataScopeKind(), grants)
}
release, err := s.instances.GetRelease(ctx, instance.ActiveReleaseID)
if errors.Is(err, plugininstances.ErrNotFound) {
@@ -755,7 +755,7 @@ func (s *Service) buildCanvas(ctx context.Context, metadata CanvasMetadata, inst
canvas.ActiveReleaseStatus = release.ValidationStatus
canvas.ActiveReleaseError = release.ValidationError
canvas.EffectiveGrants = effectiveGrantProjection(instance, ReleasePermissionSummary(release), grants)
canvas.ActiveRelease = releaseMetadata(release, instance.ScopeKind, grants)
canvas.ActiveRelease = releaseMetadata(release, instance.EffectiveDataScopeKind(), grants)
return canvas, nil
}
@@ -866,6 +866,7 @@ func canvasFromMetadataInstance(metadata CanvasMetadata, instance plugininstance
TaskID: metadata.TaskID,
OriginTaskID: metadata.OriginTaskID,
ScopeKind: instance.ScopeKind,
DataScopeKind: instance.EffectiveDataScopeKind(),
Title: metadata.Title,
CreatedBySessionID: metadata.CreatedBySessionID,
PromotedByUserID: metadata.PromotedByUserID,
@@ -896,6 +897,7 @@ func lifecycleEvent(eventType string, canvas Canvas) LifecycleEvent {
WorkspaceID: canvas.WorkspaceID,
TaskID: canvas.TaskID,
ScopeKind: canvas.ScopeKind,
DataScopeKind: canvas.DataScopeKind,
Status: canvas.Status,
ActiveReleaseID: canvas.ActiveReleaseID,
ActiveReleaseStatus: canvas.ActiveReleaseStatus,
+28 -12
View File
@@ -16,14 +16,26 @@ import (
const (
// CanvasPluginID identifies the synthetic plugin binding used by local
// canvas instances. The package identity remains in the release metadata.
CanvasPluginID = "kandev.canvas"
MaxTitleLength = 200
CreationAuthorityPolicyVersion = 1
CanvasPluginID = "kandev.canvas"
MaxTitleLength = 200
creationAuthorityTaskPolicyVersion = 1
CreationAuthorityPolicyVersion = 2
MaxTaskCanvases = plugininstances.MaxTaskInstances
MaxWorkspaceCanvases = plugininstances.MaxWorkspaceInstances
)
func creationAuthorityPolicyVersionSupported(version int) bool {
return version == creationAuthorityTaskPolicyVersion || version == CreationAuthorityPolicyVersion
}
func creationAuthorityDataScope(authority CreationAuthority) string {
if authority.PolicyVersion == CreationAuthorityPolicyVersion {
return ScopeWorkspace
}
return ScopeTask
}
const (
ScopeTask = plugininstances.ScopeTask
ScopeWorkspace = plugininstances.ScopeWorkspace
@@ -55,15 +67,16 @@ const (
)
var (
ErrCanvasNotFound = errors.New("canvas not found")
ErrInvalidCanvas = errors.New("invalid canvas")
ErrInvalidCanvasState = errors.New("invalid canvas state")
ErrCanvasMetadataBroken = errors.New("canvas metadata is inconsistent with its plugin instance")
ErrCanvasNotConfigured = errors.New("canvas lifecycle is not configured")
ErrStalePromotionReview = plugininstances.ErrStalePromotionReview
ErrStaleCanvasEdit = plugininstances.ErrStaleCanvasEdit
ErrStaleCanvasPublish = plugininstances.ErrStaleCanvasPublish
ErrInvalidLifecycleState = plugininstances.ErrInvalidLifecycleState
ErrCanvasNotFound = errors.New("canvas not found")
ErrInvalidCanvas = errors.New("invalid canvas")
ErrInvalidCanvasState = errors.New("invalid canvas state")
ErrCanvasMetadataBroken = errors.New("canvas metadata is inconsistent with its plugin instance")
ErrCanvasNotConfigured = errors.New("canvas lifecycle is not configured")
ErrStalePromotionReview = plugininstances.ErrStalePromotionReview
ErrStaleWorkspaceDataReview = plugininstances.ErrStaleWorkspaceDataReview
ErrStaleCanvasEdit = plugininstances.ErrStaleCanvasEdit
ErrStaleCanvasPublish = plugininstances.ErrStaleCanvasPublish
ErrInvalidLifecycleState = plugininstances.ErrInvalidLifecycleState
// These aliases preserve the stable plugin admission errors while keeping
// the canvas package convenient for service callers and API adapters.
@@ -109,6 +122,7 @@ type Canvas struct {
TaskID string `json:"task_id,omitempty"`
OriginTaskID string `json:"origin_task_id,omitempty"`
ScopeKind string `json:"scope_kind"`
DataScopeKind string `json:"data_scope_kind"`
Title string `json:"title"`
CreatedBySessionID string `json:"created_by_session_id,omitempty"`
PromotedByUserID string `json:"promoted_by_user_id,omitempty"`
@@ -215,6 +229,7 @@ type LifecycleEvent struct {
WorkspaceID string `json:"workspace_id"`
TaskID string `json:"task_id,omitempty"`
ScopeKind string `json:"scope_kind"`
DataScopeKind string `json:"data_scope_kind"`
Status string `json:"status"`
ActiveReleaseID string `json:"active_release_id,omitempty"`
ActiveReleaseStatus string `json:"active_release_status,omitempty"`
@@ -226,6 +241,7 @@ const (
EventReleaseActivated = "canvas.release.activated"
EventReleasePermissionRequired = "canvas.release.permission_required"
EventPromoted = "canvas.promoted"
EventWorkspaceDataEnabled = "canvas.workspace_data_enabled"
EventArchived = "canvas.archived"
EventRestored = "canvas.restored"
EventRemoved = "canvas.removed"
@@ -0,0 +1,143 @@
package canvas
import (
"context"
"errors"
"testing"
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/webapp"
)
func TestFirstPublicationRecordsWorkspaceDataTransition(t *testing.T) {
service, instanceStore, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Owner-authorized canvas",
CreatedBySessionID: "session-1", OwnerUserID: "owner-1",
})
instance, err := instanceStore.Get(context.Background(), created.PluginInstanceID)
if err != nil {
t.Fatalf("get instance: %v", err)
}
metric := "transition=first_publication;result=data_scope_enabled"
before := canvasScopeMetricCount(t, metric)
result, err := service.PublishPackage(context.Background(), PublishRequest{
CanvasID: created.ID,
Package: testCanvasPackage("workspace-metric", []string{"tasks"}),
Artifact: webapp.Artifact{Digest: "workspace-metric", RelativePath: "releases/workspace-metric", Bytes: 1},
ExpectedAuthority: instance.PublishAuthority(),
SourceActorKind: "agent",
SourceUserID: "owner-1",
SourceTaskID: "task-1",
SourceSessionID: "session-1",
})
if err != nil {
t.Fatalf("publish owner-authorized canvas: %v", err)
}
if !result.Activated {
t.Fatalf("publish result = %+v, want activated first release", result)
}
if after := canvasScopeMetricCount(t, metric); after-before != 1 {
t.Fatalf("first-publication scope metric delta = %d, want 1", after-before)
}
}
func TestWorkspaceDataUpgradePreservesPlacementAndRequiresFreshOwnerReview(t *testing.T) {
service, instanceStore, _ := newCanvasService(t)
canvas := createCanvas(t, service, CreateCanvasRequest{
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Legacy canvas",
})
published := publishTestPackage(t, service, canvas.ID, "legacy-workspace-review", []string{"tasks"})
if _, err := service.ApproveRelease(context.Background(), canvas.ID, published.Release.ID, "owner-1"); err != nil {
t.Fatalf("approve legacy task release: %v", err)
}
preview, err := service.WorkspaceDataPreview(context.Background(), canvas.ID)
if err != nil {
t.Fatalf("workspace data preview: %v", err)
}
if preview.CurrentDataScopeKind != instances.ScopeTask || preview.TargetDataScopeKind != instances.ScopeWorkspace ||
preview.ActiveReleaseID != published.Release.ID || preview.Permissions.Reads[0] != "tasks" {
t.Fatalf("workspace data preview = %+v, want task-to-workspace review of the active task read", preview)
}
if err := instanceStore.AddGrant(context.Background(), instances.Grant{
InstanceID: canvas.PluginInstanceID, PermissionKind: "api_read", Resource: "tasks",
ScopeCeiling: instances.ScopeTask, ApprovedBy: "owner-1",
}); err != nil {
t.Fatalf("advance grant generation: %v", err)
}
if err := instanceStore.AddGrant(context.Background(), instances.Grant{
InstanceID: canvas.PluginInstanceID, PermissionKind: "api_write", Resource: "messages",
ScopeCeiling: instances.ScopeTask, ApprovedBy: "owner-1",
}); err != nil {
t.Fatalf("seed stale task-ceiling grant: %v", err)
}
if _, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration); !errors.Is(err, instances.ErrStaleWorkspaceDataReview) {
t.Fatalf("stale workspace data confirmation = %v, want ErrStaleWorkspaceDataReview", err)
}
unchanged, err := instanceStore.Get(context.Background(), canvas.PluginInstanceID)
if err != nil {
t.Fatalf("get unchanged instance: %v", err)
}
if unchanged.ScopeKind != ScopeTask || unchanged.EffectiveDataScopeKind() != instances.ScopeTask {
t.Fatalf("stale review changed scopes to placement %q/data %q", unchanged.ScopeKind, unchanged.EffectiveDataScopeKind())
}
preview, err = service.WorkspaceDataPreview(context.Background(), canvas.ID)
if err != nil {
t.Fatalf("refresh workspace data preview: %v", err)
}
if _, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-2", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration); !errors.Is(err, instances.ErrWorkspaceOwnerRequired) {
t.Fatalf("foreign owner confirmation = %v, want ErrWorkspaceOwnerRequired", err)
}
metric := "transition=reviewed_upgrade;result=data_scope_enabled"
before := canvasScopeMetricCount(t, metric)
upgraded, err := service.EnableWorkspaceDataReviewed(context.Background(), canvas.ID, "owner-1", preview.ActiveReleaseID, preview.PermissionDigest, preview.GrantGeneration)
if err != nil {
t.Fatalf("owner enables workspace data: %v", err)
}
if after := canvasScopeMetricCount(t, metric); after-before != 1 {
t.Fatalf("reviewed-upgrade scope metric delta = %d, want 1", after-before)
}
if upgraded.ScopeKind != ScopeTask || upgraded.DataScopeKind != instances.ScopeWorkspace ||
upgraded.TaskID != "task-1" || upgraded.ActiveReleaseID != published.Release.ID || upgraded.PromotedAt != nil {
t.Fatalf("upgraded canvas = %+v, want unchanged task placement and active release", upgraded)
}
grants, err := instanceStore.ListGrants(context.Background(), canvas.PluginInstanceID)
if err != nil {
t.Fatalf("list upgraded grants: %v", err)
}
if len(grants) != 1 || grants[0].PermissionKind != "api_read" || grants[0].Resource != "tasks" || grants[0].ScopeCeiling != instances.ScopeWorkspace || grants[0].ApprovedBy != "owner-1" {
t.Fatalf("upgraded grants = %+v, want one owner-approved workspace task grant", grants)
}
}
func TestPromotionRecordsExpandedDataScope(t *testing.T) {
service, _, _ := newCanvasService(t)
created := createCanvas(t, service, CreateCanvasRequest{
WorkspaceID: "workspace-1", TaskID: "task-1", Title: "Promoted canvas",
})
published := publishTestPackage(t, service, created.ID, "promotion-scope-metric", []string{"tasks"})
if _, err := service.ApproveRelease(context.Background(), created.ID, published.Release.ID, "owner-1"); err != nil {
t.Fatalf("approve task-scoped release: %v", err)
}
preview, err := service.PromotionPreview(context.Background(), created.ID)
if err != nil {
t.Fatalf("promotion preview: %v", err)
}
metric := "transition=promotion;result=data_scope_expanded"
before := canvasScopeMetricCount(t, metric)
promoted, err := service.PromoteCanvasReviewed(
context.Background(), created.ID, "owner-1", preview.ActiveReleaseID,
preview.PermissionDigest, preview.GrantGeneration,
)
if err != nil {
t.Fatalf("promote task-scoped canvas: %v", err)
}
if promoted.ScopeKind != ScopeWorkspace || promoted.DataScopeKind != instances.ScopeWorkspace {
t.Fatalf("promoted scopes = placement %q, data %q, want workspace/workspace", promoted.ScopeKind, promoted.DataScopeKind)
}
if after := canvasScopeMetricCount(t, metric); after-before != 1 {
t.Fatalf("promotion scope metric delta = %d, want 1", after-before)
}
}
+1
View File
@@ -41,6 +41,7 @@ const (
CanvasReleaseActivated = "canvas.release.activated"
CanvasReleasePermissionRequired = "canvas.release.permission_required"
CanvasPromoted = "canvas.promoted"
CanvasWorkspaceDataEnabled = "canvas.workspace_data_enabled"
CanvasArchived = "canvas.archived"
CanvasRestored = "canvas.restored"
CanvasRemoved = "canvas.removed"
@@ -44,6 +44,7 @@ func RegisterCanvasNotifications(ctx context.Context, eventBus bus.EventBus, hub
{events.CanvasReleaseActivated, ws.ActionCanvasReleaseActivated},
{events.CanvasReleasePermissionRequired, ws.ActionCanvasReleasePermissionRequired},
{events.CanvasPromoted, ws.ActionCanvasPromoted},
{events.CanvasWorkspaceDataEnabled, ws.ActionCanvasWorkspaceDataEnabled},
{events.CanvasArchived, ws.ActionCanvasArchived},
{events.CanvasRestored, ws.ActionCanvasRestored},
{events.CanvasRemoved, ws.ActionCanvasRemoved},
@@ -27,12 +27,13 @@ core bundle again during the same authoring task.
ID and source path. Read validation diagnostics and correct rejected source
before publishing again.
The first valid release of a new owner-created task canvas uses the returned
The first valid release of a new owner-authorized task canvas uses the returned
initial permission policy. It can activate without a second approval for its
declared supported task-scoped data, event, state, and exact HTTPS-origin
permissions. A later permission increase, imported package, or workspace
promotion still requires human review. Do not add a trust flag to the
manifest, and do not request permissions outside the policy.
declared supported permissions. Kandev data access is limited to the current
workspace, while the canvas remains placed in its creating task. Imported
packages and later permission increases need human review. Promotion changes
workspace navigation. Do not add a trust flag to the manifest, and do not
request permissions outside the policy.
## Core application contract
@@ -66,7 +67,8 @@ Use the returned `manifest_scaffold` as the starting point. New manifests use
least one `task-canvas` or `workspace-canvas` placement. Declare only the
`api_read`, `api_write`, `events`, `state`, and `network_origins` permissions
that the application needs. The owner-authorized first release can receive
only these supported task-scoped grants. The entry and all relative assets
only its declared supported permissions, with Kandev data limited to the
current workspace. The entry and all relative assets
must be in the published package.
## Browser protocol summary
@@ -24,6 +24,7 @@ show a retry state for transient reads.
"web_app_key": "main",
"placement": "task-canvas",
"scope_kind": "task",
"data_scope_kind": "workspace",
"workspace_id": "workspace-id",
"task_id": "task-id",
"session_id": "session-id",
@@ -34,7 +35,9 @@ show a retry state for transient reads.
Scope identifiers are omitted when they do not apply. `capabilities` contains
the effective, approved permission keys. It is not a replacement for handling
permission errors from later requests.
permission errors from later requests. `scope_kind` describes canvas placement
and lifecycle. `data_scope_kind` describes the Kandev data boundary. A task
canvas can have `scope_kind: "task"` and `data_scope_kind: "workspace"`.
## Data routes
@@ -51,9 +54,11 @@ All data responses use JSON. Collection responses use this envelope:
and accepts any value from 1 to 200. Unlike the gRPC Host API, this surface
does not clamp an out-of-range `limit` into that window: a supplied value
outside 1..200 is rejected outright with HTTP 400 `invalid_request`, and only
an omitted `limit` falls back to the 50-row default. A task-scoped canvas is
restricted to its task. A workspace-scoped canvas is restricted to its
workspace.
an omitted `limit` falls back to the 50-row default. Task data scope returns
only the bound task. Workspace data scope returns tasks from the instance's
trusted workspace, including when the canvas is still placed in one task. A
`workspace_id` filter must match that workspace; another workspace is denied.
Follow `page_info.next_cursor` until it is omitted to load every page.
| Method | Route | Permission | Use |
| --- | --- | --- | --- |
@@ -67,10 +67,11 @@ the old direct requests cannot continue under the old binding.
Capabilities are declarations, not grants. Use `api_read` for `tasks` and
`workflows`, `api_write` for `tasks` and `messages`, `events` for event
subscriptions, and `state: true` for instance state. The owner-authorized
first release can receive only these supported task-scoped grants. The
operator reviews a later permission increase, an imported package, or a
workspace promotion. Request only the capabilities used by the application.
subscriptions, and `state: true` for instance state. The first owner-authorized
release can receive only its declared supported permissions, with Kandev data
limited to the current workspace. The canvas remains placed in its creating
task until promotion. Imported packages and later permission increases need
human review. Request only the capabilities used by the application.
The package must include the declared entry document and every local asset it
references. Bundle executable dependencies. A build tool, package manager, or
+209 -34
View File
@@ -21,6 +21,7 @@ import (
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/db"
userstore "github.com/kandev/kandev/internal/user/store"
)
const (
@@ -66,6 +67,8 @@ var (
ErrInstallationStorageLimit = errors.New("canvas installation storage limit exceeded")
ErrInvalidRelease = errors.New("invalid plugin release")
ErrStalePromotionReview = errors.New("canvas promotion review is stale")
ErrStaleWorkspaceDataReview = errors.New("canvas workspace data review is stale")
ErrWorkspaceOwnerRequired = errors.New("current workspace owner is required")
ErrStaleCanvasEdit = errors.New("canvas edit base release is stale")
ErrStaleCanvasPublish = errors.New("canvas publish authority is stale")
ErrInvalidLifecycleState = errors.New("plugin instance lifecycle state does not allow this operation")
@@ -100,6 +103,16 @@ func ValidateScope(kind string, ids ScopeIdentifiers) error {
return nil
}
func validateDataScope(sourceKind, placementScope, dataScope string) error {
if dataScope == "" || dataScope == placementScope {
return nil
}
if sourceKind == SourceLocalCanvas && placementScope == ScopeTask && dataScope == ScopeWorkspace {
return nil
}
return ErrInvalidScope
}
func scopeIdentifiersFor(kind string) map[string]bool {
switch kind {
case ScopeInstance:
@@ -122,6 +135,7 @@ type Instance struct {
PluginID string
SourceKind string
ScopeKind string
DataScopeKind string
WorkspaceID string
TaskID string
SessionID string
@@ -133,6 +147,21 @@ type Instance struct {
UpdatedAt time.Time
}
// EffectiveDataScopeKind preserves the placement-bound behavior of stored
// instances whose nullable data scope predates the independent scope field.
func (i Instance) EffectiveDataScopeKind() string {
return EffectiveDataScopeKind(i.ScopeKind, i.DataScopeKind)
}
// EffectiveDataScopeKind resolves legacy rows and runtime bindings that do
// not carry a separate data scope.
func EffectiveDataScopeKind(placementScope, dataScope string) string {
if dataScope == "" {
return placementScope
}
return dataScope
}
// PublishAuthority is the durable identity captured when a trusted authoring
// request is authorized. A publish must match every field in this snapshot at
// the release insertion boundary so a scope transition, archive, or competing
@@ -141,6 +170,7 @@ type Instance struct {
type PublishAuthority struct {
InstanceID string
ScopeKind string
DataScopeKind string
WorkspaceID string
TaskID string
SessionID string
@@ -152,14 +182,14 @@ type PublishAuthority struct {
func (i Instance) PublishAuthority() PublishAuthority {
return PublishAuthority{
InstanceID: i.ID, ScopeKind: i.ScopeKind, WorkspaceID: i.WorkspaceID,
InstanceID: i.ID, ScopeKind: i.ScopeKind, DataScopeKind: i.EffectiveDataScopeKind(), WorkspaceID: i.WorkspaceID,
TaskID: i.TaskID, SessionID: i.SessionID, RepositoryID: i.RepositoryID,
Status: i.Status, ActiveReleaseID: i.ActiveReleaseID, GrantGeneration: i.GrantGeneration,
}
}
func (a PublishAuthority) IsZero() bool {
return a.InstanceID == "" && a.ScopeKind == "" && a.WorkspaceID == "" &&
return a.InstanceID == "" && a.ScopeKind == "" && a.DataScopeKind == "" && a.WorkspaceID == "" &&
a.TaskID == "" && a.SessionID == "" && a.RepositoryID == "" &&
a.Status == "" && a.ActiveReleaseID == "" && a.GrantGeneration == 0
}
@@ -247,6 +277,7 @@ CREATE TABLE IF NOT EXISTS plugin_instances (
plugin_id TEXT NOT NULL,
source_kind TEXT NOT NULL,
scope_kind TEXT NOT NULL,
data_scope_kind TEXT,
workspace_id TEXT NOT NULL DEFAULT '',
task_id TEXT NOT NULL DEFAULT '',
session_id TEXT NOT NULL DEFAULT '',
@@ -321,6 +352,9 @@ func (s *Store) initSchema() error {
return err
}
}
if _, err := s.db.Exec(`ALTER TABLE plugin_instances ADD COLUMN data_scope_kind TEXT`); err != nil && !db.IsDuplicateColumnError(err) {
return err
}
return nil
}
@@ -358,6 +392,9 @@ func (s *Store) CreateTx(ctx context.Context, tx *sqlx.Tx, instance Instance) er
if err := ValidateScope(instance.ScopeKind, ScopeIdentifiers{WorkspaceID: instance.WorkspaceID, TaskID: instance.TaskID, SessionID: instance.SessionID, RepositoryID: instance.RepositoryID}); err != nil {
return err
}
if err := validateDataScope(instance.SourceKind, instance.ScopeKind, instance.DataScopeKind); err != nil {
return err
}
if err := s.checkAdmission(ctx, tx, instance.ScopeKind, instance.WorkspaceID, instance.TaskID); err != nil {
return err
}
@@ -370,8 +407,8 @@ func (s *Store) CreateTx(ctx context.Context, tx *sqlx.Tx, instance Instance) er
updatedAt = createdAt
}
_, err := tx.ExecContext(ctx, tx.Rebind(`
INSERT INTO plugin_instances (id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), instance.ID, instance.PluginID, instance.SourceKind, instance.ScopeKind, instance.WorkspaceID, instance.TaskID, instance.SessionID, instance.RepositoryID, instance.Status, instance.ActiveReleaseID, instance.GrantGeneration, createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano))
INSERT INTO plugin_instances (id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at)
VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?, ?, ?)`), instance.ID, instance.PluginID, instance.SourceKind, instance.ScopeKind, instance.DataScopeKind, instance.WorkspaceID, instance.TaskID, instance.SessionID, instance.RepositoryID, instance.Status, instance.ActiveReleaseID, instance.GrantGeneration, createdAt.Format(time.RFC3339Nano), updatedAt.Format(time.RFC3339Nano))
if err != nil {
return err
}
@@ -385,7 +422,7 @@ func (s *Store) ListBySource(ctx context.Context, sourceKind string, includeRemo
if strings.TrimSpace(sourceKind) == "" {
return nil, ErrInvalidScope
}
query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE source_kind = ?`
query := `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE source_kind = ?`
args := []any{sourceKind}
if !includeRemoved {
query += ` AND status <> ?`
@@ -453,7 +490,7 @@ func (s *Store) checkAdmissionExcept(ctx context.Context, tx *sqlx.Tx, scopeKind
func (s *Store) Get(ctx context.Context, id string) (Instance, error) {
var row instanceRow
err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id)
err := s.ro.GetContext(ctx, &row, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id)
if errors.Is(err, sql.ErrNoRows) {
return Instance{}, ErrNotFound
}
@@ -487,7 +524,7 @@ func (s *Store) GetMany(ctx context.Context, ids []string) ([]Instance, error) {
for i, id := range unique {
args[i] = id
}
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id IN (`+placeholders+`)`), args...)
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id IN (`+placeholders+`)`), args...)
if err != nil {
return nil, err
}
@@ -508,7 +545,7 @@ func (s *Store) GetMany(ctx context.Context, ids []string) ([]Instance, error) {
}
func (s *Store) List(ctx context.Context, workspaceID string, includeRemoved bool) ([]Instance, error) {
query := `SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE workspace_id = ?`
query := `SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE workspace_id = ?`
args := []any{workspaceID}
if !includeRemoved {
query += ` AND status <> ?`
@@ -558,7 +595,7 @@ func (s *Store) Restore(ctx context.Context, id string) error {
}
defer func() { _ = tx.Rollback() }()
var row instanceRow
if err := tx.GetContext(ctx, &row, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
if err := tx.GetContext(ctx, &row, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -611,7 +648,7 @@ func (s *Store) SetScope(ctx context.Context, id, scopeKind string, ids ScopeIde
}
defer func() { _ = tx.Rollback() }()
var current instanceRow
if err := tx.GetContext(ctx, &current, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
if err := tx.GetContext(ctx, &current, tx.Rebind(`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
@@ -620,6 +657,9 @@ func (s *Store) SetScope(ctx context.Context, id, scopeKind string, ids ScopeIde
if current.Status == StatusRemoved {
return ErrNotFound
}
if err := validateDataScope(current.SourceKind, scopeKind, current.DataScopeKind.String); err != nil {
return err
}
if err := s.checkAdmissionExcept(ctx, tx, scopeKind, ids.WorkspaceID, ids.TaskID, id); err != nil {
return err
}
@@ -718,7 +758,7 @@ func (s *Store) ActivateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID,
}
var current instanceRow
if err := tx.GetContext(ctx, &current, tx.Rebind(
`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
), instanceID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
@@ -743,7 +783,7 @@ func (s *Store) ActivateReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID,
}
return err
}
if release.ValidationStatus != ValidationValid || !declaredGrantsFitInstance(ctx, tx, instanceID, current.ScopeKind, release.DeclaredPermissionsJSON) {
if release.ValidationStatus != ValidationValid || !declaredGrantsFitInstance(ctx, tx, instanceID, EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind.String), release.DeclaredPermissionsJSON) {
return ErrInvalidRelease
}
result, err := tx.ExecContext(ctx, tx.Rebind(
@@ -1106,6 +1146,79 @@ func (s *Store) PromoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx
return s.promoteScopeAndGrantsReviewedTx(ctx, tx, instanceID, workspaceID, approvedBy, grants, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration)
}
// EnableWorkspaceDataReviewedTx widens only a retained task canvas's data
// scope after the current workspace owner confirms its current release.
func (s *Store) EnableWorkspaceDataReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
if err := validateWorkspaceDataReviewRequest(instanceID, workspaceID, approvedBy, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil {
return err
}
current, declaredJSON, err := loadPromotionState(ctx, tx, instanceID)
if err != nil {
return err
}
if err := validateWorkspaceDataReviewState(current, declaredJSON, workspaceID, expectedReleaseID, expectedPermissionDigest, expectedGrantGeneration); err != nil {
return err
}
if err := validateGrantsForDeclaration(declaredJSON, ScopeWorkspace, grants); err != nil {
return err
}
if err := verifyWorkspaceOwnerTx(ctx, tx, workspaceID, approvedBy); err != nil {
return err
}
return updateWorkspaceDataScopeTx(ctx, tx, current, workspaceID, approvedBy, grants, expectedReleaseID, expectedGrantGeneration)
}
func validateWorkspaceDataReviewRequest(instanceID, workspaceID, approvedBy, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(workspaceID) == "" || strings.TrimSpace(approvedBy) == "" ||
strings.TrimSpace(expectedReleaseID) == "" || strings.TrimSpace(expectedPermissionDigest) == "" || expectedGrantGeneration < 0 {
return ErrStaleWorkspaceDataReview
}
return nil
}
func validateWorkspaceDataReviewState(current instanceRow, declaredJSON, workspaceID, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
if current.SourceKind != SourceLocalCanvas || current.ScopeKind != ScopeTask || current.WorkspaceID != workspaceID ||
current.TaskID == "" || current.Status != StatusActive || current.DataScopeKind.Valid && current.DataScopeKind.String == ScopeWorkspace {
return ErrInvalidLifecycleState
}
if current.ActiveReleaseID != expectedReleaseID || current.GrantGeneration != expectedGrantGeneration ||
permissionDigest(declaredJSON) != expectedPermissionDigest || EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind.String) != ScopeTask {
return ErrStaleWorkspaceDataReview
}
return nil
}
func verifyWorkspaceOwnerTx(ctx context.Context, tx *sqlx.Tx, workspaceID, approvedBy string) error {
ownerUpdate, err := tx.ExecContext(ctx, tx.Rebind(
`UPDATE workspaces SET owner_id = owner_id WHERE id = ? AND (COALESCE(owner_id, '') = ? OR (COALESCE(owner_id, '') = '' AND ? = ?))`,
), workspaceID, approvedBy, approvedBy, userstore.DefaultUserID)
if err != nil {
return err
}
if affected, _ := ownerUpdate.RowsAffected(); affected == 0 {
return ErrWorkspaceOwnerRequired
}
return nil
}
func updateWorkspaceDataScopeTx(ctx context.Context, tx *sqlx.Tx, current instanceRow, workspaceID, approvedBy string, grants []Grant, expectedReleaseID string, expectedGrantGeneration int64) error {
result, err := tx.ExecContext(ctx, tx.Rebind(
`UPDATE plugin_instances SET data_scope_kind = ?, grant_generation = grant_generation + 1, updated_at = ? WHERE id = ? AND source_kind = ? AND scope_kind = ? AND workspace_id = ? AND task_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ? AND COALESCE(data_scope_kind, '') = ?`,
), ScopeWorkspace, time.Now().UTC().Format(time.RFC3339Nano), current.ID, SourceLocalCanvas, ScopeTask, workspaceID, current.TaskID, StatusActive, expectedReleaseID, expectedGrantGeneration, current.DataScopeKind.String)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return ErrStaleWorkspaceDataReview
}
if _, err := tx.ExecContext(ctx, tx.Rebind(
`DELETE FROM plugin_instance_grants WHERE plugin_instance_id = ?`,
), current.ID); err != nil {
return err
}
return insertInstanceGrantsTx(ctx, tx, current.ID, approvedBy, grants)
}
func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx, instanceID, workspaceID, approvedBy string, grants []Grant, expectedReleaseID, expectedPermissionDigest string, expectedGrantGeneration int64) error {
if err := validatePromotionRequest(instanceID, workspaceID, approvedBy); err != nil {
return err
@@ -1129,7 +1242,7 @@ func (s *Store) promoteScopeAndGrantsReviewedTx(ctx context.Context, tx *sqlx.Tx
func loadPromotionState(ctx context.Context, tx *sqlx.Tx, instanceID string) (instanceRow, string, error) {
var current instanceRow
if err := tx.GetContext(ctx, &current, tx.Rebind(
`SELECT id, plugin_id, source_kind, scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
`SELECT id, plugin_id, source_kind, scope_kind, data_scope_kind, workspace_id, task_id, session_id, repository_id, status, active_release_id, grant_generation, created_at, updated_at FROM plugin_instances WHERE id = ?`,
), instanceID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return instanceRow{}, "", ErrNotFound
@@ -1231,14 +1344,20 @@ func (s *Store) ApproveReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, r
if release.ValidationStatus != ValidationPendingPermission {
return ErrInvalidRelease
}
if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, grants); err != nil {
effectiveScope := EffectiveDataScopeKind(release.ScopeKind, release.DataScopeKind)
if release.SourceKind == SourceLocalCanvas && effectiveScope == ScopeWorkspace {
if err := verifyWorkspaceOwnerTx(ctx, tx, release.WorkspaceID, approvedBy); err != nil {
return err
}
}
if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, effectiveScope, grants); err != nil {
return err
}
existingGrants, err := loadInstanceGrants(ctx, tx, instanceID)
if err != nil {
return err
}
if !grantsCoverDeclaration(release.DeclaredPermissionsJSON, release.ScopeKind, append(existingGrants, grants...)) {
if !grantsCoverDeclaration(release.DeclaredPermissionsJSON, effectiveScope, append(existingGrants, grants...)) {
return ErrInvalidScope
}
if err := insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants); err != nil {
@@ -1262,14 +1381,17 @@ type pendingReleaseRow struct {
PluginID string `db:"plugin_id"`
DeclaredPermissionsJSON string `db:"declared_permissions_json"`
ValidationStatus string `db:"validation_status"`
SourceKind string `db:"source_kind"`
WorkspaceID string `db:"workspace_id"`
ScopeKind string `db:"scope_kind"`
DataScopeKind string `db:"data_scope_kind"`
Status string `db:"status"`
}
func loadPendingReleaseTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID string) (pendingReleaseRow, error) {
var release pendingReleaseRow
if err := tx.GetContext(ctx, &release, tx.Rebind(
`SELECT r.plugin_id, r.declared_permissions_json, r.validation_status, i.scope_kind, i.status FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE r.id = ? AND r.instance_id = ?`,
`SELECT r.plugin_id, r.declared_permissions_json, r.validation_status, i.source_kind, i.workspace_id, i.scope_kind, COALESCE(i.data_scope_kind, '') AS data_scope_kind, i.status FROM plugin_releases r JOIN plugin_instances i ON i.id = r.instance_id WHERE r.id = ? AND r.instance_id = ?`,
), releaseID, instanceID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return pendingReleaseRow{}, ErrNotFound
@@ -1383,8 +1505,8 @@ func (s *Store) CreateReleaseIfAuthorityTx(ctx context.Context, tx *sqlx.Tx, ins
return ErrInvalidRelease
}
result, err := tx.ExecContext(ctx, tx.Rebind(
`UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND scope_kind = ? AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ?`,
), instanceID, expected.ScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration)
`UPDATE plugin_instances SET updated_at = updated_at WHERE id = ? AND scope_kind = ? AND COALESCE(NULLIF(data_scope_kind, ''), scope_kind) = ? AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ? AND status = ? AND active_release_id = ? AND grant_generation = ?`,
), instanceID, expected.ScopeKind, expected.DataScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID, expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration)
if err != nil {
return err
}
@@ -1659,8 +1781,8 @@ func (s *Store) AddGrant(ctx context.Context, grant Grant) error {
// canvas creation. It does not advance grant_generation because SetPluginIDTx
// increments it before the public ActivateReleaseTx call in the same
// activation transaction. The empty-grant case is valid.
func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, approvedBy string, grants []Grant) error {
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(approvedBy) == "" {
func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID, releaseID, approvedBy string, grants []Grant) error {
if strings.TrimSpace(instanceID) == "" || strings.TrimSpace(releaseID) == "" || strings.TrimSpace(approvedBy) == "" {
return ErrInvalidScope
}
var exists int
@@ -1672,6 +1794,27 @@ func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID,
if exists == 0 {
return ErrNotFound
}
var current struct {
ScopeKind string `db:"scope_kind"`
DataScopeKind string `db:"data_scope_kind"`
}
if err := tx.GetContext(ctx, &current, tx.Rebind(
`SELECT scope_kind, COALESCE(data_scope_kind, '') AS data_scope_kind FROM plugin_instances WHERE id = ?`,
), instanceID); err != nil {
return err
}
var release struct {
DeclaredPermissionsJSON string `db:"declared_permissions_json"`
ValidationStatus string `db:"validation_status"`
}
if err := tx.GetContext(ctx, &release, tx.Rebind(
`SELECT declared_permissions_json, validation_status FROM plugin_releases WHERE id = ? AND instance_id = ?`,
), releaseID, instanceID); err != nil || release.ValidationStatus != ValidationValid {
return ErrInvalidRelease
}
if err := validateGrantsForDeclaration(release.DeclaredPermissionsJSON, EffectiveDataScopeKind(current.ScopeKind, current.DataScopeKind), grants); err != nil {
return err
}
var existing int
if err := tx.GetContext(ctx, &existing, tx.Rebind(
`SELECT COUNT(*) FROM plugin_instance_grants WHERE plugin_instance_id = ?`,
@@ -1684,6 +1827,37 @@ func (s *Store) AddInitialGrantsTx(ctx context.Context, tx *sqlx.Tx, instanceID,
return insertInstanceGrantsTx(ctx, tx, instanceID, approvedBy, grants)
}
// SetInitialPublicationDataScopeTx widens a pending task canvas only after its
// creation authority has been consumed in the caller's transaction.
func (s *Store) SetInitialPublicationDataScopeTx(ctx context.Context, tx *sqlx.Tx, expected PublishAuthority, releaseID string) error {
if expected.InstanceID == "" || expected.ScopeKind != ScopeTask || expected.DataScopeKind != ScopeTask ||
expected.Status != StatusPending || expected.ActiveReleaseID != "" || expected.WorkspaceID == "" ||
expected.TaskID == "" || strings.TrimSpace(releaseID) == "" {
return ErrStaleCanvasPublish
}
result, err := tx.ExecContext(ctx, tx.Rebind(
`UPDATE plugin_instances
SET data_scope_kind = ?, grant_generation = grant_generation + 1, updated_at = ?
WHERE id = ? AND source_kind = ? AND scope_kind = ? AND COALESCE(NULLIF(data_scope_kind, ''), scope_kind) = ?
AND workspace_id = ? AND task_id = ? AND session_id = ? AND repository_id = ?
AND status = ? AND active_release_id = ? AND grant_generation = ?
AND NOT EXISTS (SELECT 1 FROM plugin_instance_grants WHERE plugin_instance_id = ?)
AND EXISTS (SELECT 1 FROM plugin_releases WHERE instance_id = ? AND id = ? AND validation_status = ?)
AND NOT EXISTS (SELECT 1 FROM plugin_releases WHERE instance_id = ? AND id <> ?)`),
ScopeWorkspace, time.Now().UTC().Format(time.RFC3339Nano), expected.InstanceID, SourceLocalCanvas,
expected.ScopeKind, expected.DataScopeKind, expected.WorkspaceID, expected.TaskID, expected.SessionID,
expected.RepositoryID, expected.Status, expected.ActiveReleaseID, expected.GrantGeneration,
expected.InstanceID, expected.InstanceID, releaseID, ValidationValid, expected.InstanceID, releaseID,
)
if err != nil {
return err
}
if affected, _ := result.RowsAffected(); affected == 0 {
return ErrStaleCanvasPublish
}
return nil
}
func (s *Store) ListGrants(ctx context.Context, instanceID string) ([]Grant, error) {
rows, err := s.ro.QueryxContext(ctx, s.ro.Rebind(`SELECT plugin_instance_id, permission_kind, resource, network_origin, scope_ceiling, approved_by, approved_at FROM plugin_instance_grants WHERE plugin_instance_id = ? ORDER BY permission_kind, resource, network_origin`), instanceID)
if err != nil {
@@ -2210,19 +2384,20 @@ func (s *Store) RemoveInstanceTx(ctx context.Context, tx *sqlx.Tx, id string) er
}
type instanceRow struct {
ID string `db:"id"`
PluginID string `db:"plugin_id"`
SourceKind string `db:"source_kind"`
ScopeKind string `db:"scope_kind"`
WorkspaceID string `db:"workspace_id"`
TaskID string `db:"task_id"`
SessionID string `db:"session_id"`
RepositoryID string `db:"repository_id"`
Status string `db:"status"`
ActiveReleaseID string `db:"active_release_id"`
GrantGeneration int64 `db:"grant_generation"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
ID string `db:"id"`
PluginID string `db:"plugin_id"`
SourceKind string `db:"source_kind"`
ScopeKind string `db:"scope_kind"`
DataScopeKind sql.NullString `db:"data_scope_kind"`
WorkspaceID string `db:"workspace_id"`
TaskID string `db:"task_id"`
SessionID string `db:"session_id"`
RepositoryID string `db:"repository_id"`
Status string `db:"status"`
ActiveReleaseID string `db:"active_release_id"`
GrantGeneration int64 `db:"grant_generation"`
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
func (r instanceRow) instance() (Instance, error) {
@@ -2234,7 +2409,7 @@ func (r instanceRow) instance() (Instance, error) {
if err != nil {
return Instance{}, err
}
return Instance{ID: r.ID, PluginID: r.PluginID, SourceKind: r.SourceKind, ScopeKind: r.ScopeKind, WorkspaceID: r.WorkspaceID, TaskID: r.TaskID, SessionID: r.SessionID, RepositoryID: r.RepositoryID, Status: r.Status, ActiveReleaseID: r.ActiveReleaseID, GrantGeneration: r.GrantGeneration, CreatedAt: created, UpdatedAt: updated}, nil
return Instance{ID: r.ID, PluginID: r.PluginID, SourceKind: r.SourceKind, ScopeKind: r.ScopeKind, DataScopeKind: r.DataScopeKind.String, WorkspaceID: r.WorkspaceID, TaskID: r.TaskID, SessionID: r.SessionID, RepositoryID: r.RepositoryID, Status: r.Status, ActiveReleaseID: r.ActiveReleaseID, GrantGeneration: r.GrantGeneration, CreatedAt: created, UpdatedAt: updated}, nil
}
type releaseRow struct {
@@ -14,6 +14,7 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/db"
userstore "github.com/kandev/kandev/internal/user/store"
)
func newTestStore(t *testing.T) *Store {
@@ -44,6 +45,181 @@ func TestValidateScopeRequiresOnlyIdentifiersForScope(t *testing.T) {
}
}
func TestInstanceDataScopeIsIndependentAndDefaultsToPlacement(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
if err := store.Create(ctx, Instance{
ID: "workspace-data", PluginID: "canvas-board", SourceKind: SourceLocalCanvas,
ScopeKind: ScopeTask, DataScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1",
TaskID: "task-1", Status: StatusActive,
}); err != nil {
t.Fatalf("Create workspace-data task canvas: %v", err)
}
workspaceData, err := store.Get(ctx, "workspace-data")
if err != nil {
t.Fatalf("Get workspace-data task canvas: %v", err)
}
if workspaceData.ScopeKind != ScopeTask || workspaceData.EffectiveDataScopeKind() != ScopeWorkspace {
t.Fatalf("scopes = placement %q, effective data %q, want task and workspace", workspaceData.ScopeKind, workspaceData.EffectiveDataScopeKind())
}
if err := store.Create(ctx, Instance{
ID: "legacy", PluginID: "canvas-board", SourceKind: SourceLocalCanvas,
ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-2", Status: StatusActive,
}); err != nil {
t.Fatalf("Create legacy task canvas: %v", err)
}
legacy, err := store.Get(ctx, "legacy")
if err != nil {
t.Fatalf("Get legacy task canvas: %v", err)
}
if legacy.DataScopeKind != "" || legacy.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("legacy scopes = explicit %q, effective %q, want empty and task", legacy.DataScopeKind, legacy.EffectiveDataScopeKind())
}
}
func TestInstanceRejectsUntrustedDataScopeCombinations(t *testing.T) {
store := newTestStore(t)
for _, instance := range []Instance{
{ID: "installed", PluginID: "plugin", SourceKind: SourceInstalled, ScopeKind: ScopeTask, DataScopeKind: ScopeWorkspace, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive},
{ID: "session-data", PluginID: "canvas", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, DataScopeKind: ScopeSession, WorkspaceID: "workspace-1", TaskID: "task-2", Status: StatusActive},
{ID: "instance", PluginID: "canvas", SourceKind: SourceLocalCanvas, ScopeKind: ScopeTask, DataScopeKind: ScopeInstance, WorkspaceID: "workspace-1", TaskID: "task-3", Status: StatusActive},
} {
if err := store.Create(context.Background(), instance); !errors.Is(err, ErrInvalidScope) {
t.Errorf("Create(%s) = %v, want ErrInvalidScope", instance.ID, err)
}
}
}
func TestExistingInstanceTableMigratesWithTaskDataScopeFallback(t *testing.T) {
connection, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "legacy-instances.db"))
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
connection.SetMaxOpenConns(1)
pool := db.NewPool(sqlx.NewDb(connection, "sqlite3"), sqlx.NewDb(connection, "sqlite3"))
t.Cleanup(func() { _ = pool.Close() })
if _, err := pool.Writer().Exec(`CREATE TABLE plugin_instances (
id TEXT PRIMARY KEY, plugin_id TEXT NOT NULL, source_kind TEXT NOT NULL, scope_kind TEXT NOT NULL,
workspace_id TEXT NOT NULL DEFAULT '', task_id TEXT NOT NULL DEFAULT '', session_id TEXT NOT NULL DEFAULT '',
repository_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, active_release_id TEXT NOT NULL DEFAULT '',
grant_generation INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
)`); err != nil {
t.Fatalf("create legacy plugin_instances schema: %v", err)
}
created := time.Now().UTC().Format(time.RFC3339Nano)
if _, err := pool.Writer().Exec(`INSERT INTO plugin_instances
(id, plugin_id, source_kind, scope_kind, workspace_id, task_id, status, created_at, updated_at)
VALUES ('legacy', 'canvas', 'local_canvas', 'task', 'workspace-1', 'task-1', 'active', ?, ?)`, created, created); err != nil {
t.Fatalf("insert legacy instance: %v", err)
}
store, err := NewStore(pool)
if err != nil {
t.Fatalf("NewStore migrates legacy table: %v", err)
}
instance, err := store.Get(context.Background(), "legacy")
if err != nil {
t.Fatalf("Get legacy instance: %v", err)
}
if instance.DataScopeKind != "" || instance.EffectiveDataScopeKind() != ScopeTask {
t.Fatalf("legacy scopes = explicit %q, effective %q, want empty and task", instance.DataScopeKind, instance.EffectiveDataScopeKind())
}
}
func TestEnableWorkspaceDataRequiresCurrentOwnerReview(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
if _, err := store.db.Exec(`CREATE TABLE workspaces (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL DEFAULT '')`); err != nil {
t.Fatalf("create workspaces table: %v", err)
}
if _, err := store.db.Exec(`INSERT INTO workspaces (id, owner_id) VALUES ('workspace-1', 'owner-1')`); err != nil {
t.Fatalf("insert workspace: %v", err)
}
if err := store.Create(ctx, Instance{
ID: "instance-1", PluginID: "canvas", SourceKind: SourceLocalCanvas,
ScopeKind: ScopeTask, WorkspaceID: "workspace-1", TaskID: "task-1", Status: StatusActive,
}); err != nil {
t.Fatalf("create task canvas: %v", err)
}
declared := json.RawMessage(`{"reads":["tasks"]}`)
if err := store.CreateRelease(ctx, Release{
ID: "release-1", PluginID: "canvas", InstanceID: "instance-1", PackageDigest: "digest-1",
SourceKind: SourceLocalCanvas, ManifestJSON: json.RawMessage(`{}`), DeclaredPermissionsJSON: declared,
ArtifactPath: "releases/one", ArtifactBytes: 1, ValidationStatus: ValidationValid,
}); err != nil {
t.Fatalf("create active release: %v", err)
}
if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeTask, ApprovedBy: "owner-1"}); err != nil {
t.Fatalf("add task grant: %v", err)
}
if err := store.AddGrant(ctx, Grant{InstanceID: "instance-1", PermissionKind: "api_write", Resource: "messages", ScopeCeiling: ScopeTask, ApprovedBy: "owner-1"}); err != nil {
t.Fatalf("add stale task grant: %v", err)
}
if err := store.SetActiveRelease(ctx, "instance-1", "release-1"); err != nil {
t.Fatalf("activate release: %v", err)
}
instanceBeforeReview, err := store.Get(ctx, "instance-1")
if err != nil {
t.Fatalf("get instance before review: %v", err)
}
workspaceGrant := []Grant{{InstanceID: "instance-1", PermissionKind: "api_read", Resource: "tasks", ScopeCeiling: ScopeWorkspace}}
apply := func(userID string, generation int64, grants []Grant) error {
return store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
return store.EnableWorkspaceDataReviewedTx(ctx, tx, "instance-1", "workspace-1", userID, grants, "release-1", permissionDigest(string(declared)), generation)
})
}
if err := apply("owner-2", instanceBeforeReview.GrantGeneration, workspaceGrant); !errors.Is(err, ErrWorkspaceOwnerRequired) {
t.Fatalf("foreign owner confirmation = %v, want ErrWorkspaceOwnerRequired", err)
}
if err := apply("owner-1", instanceBeforeReview.GrantGeneration+1, workspaceGrant); !errors.Is(err, ErrStaleWorkspaceDataReview) {
t.Fatalf("stale review confirmation = %v, want ErrStaleWorkspaceDataReview", err)
}
undeclared := append(append([]Grant(nil), workspaceGrant...), Grant{InstanceID: "instance-1", PermissionKind: "api_write", Resource: "messages", ScopeCeiling: ScopeWorkspace})
if err := apply("owner-1", instanceBeforeReview.GrantGeneration, undeclared); !errors.Is(err, ErrInvalidScope) {
t.Fatalf("undeclared grant confirmation = %v, want ErrInvalidScope", err)
}
if err := apply("owner-1", instanceBeforeReview.GrantGeneration, workspaceGrant); err != nil {
t.Fatalf("owner confirmation: %v", err)
}
instance, err := store.Get(ctx, "instance-1")
if err != nil {
t.Fatalf("get upgraded instance: %v", err)
}
if instance.ScopeKind != ScopeTask || instance.DataScopeKind != ScopeWorkspace || instance.ActiveReleaseID != "release-1" || instance.GrantGeneration != instanceBeforeReview.GrantGeneration+1 {
t.Fatalf("upgraded instance = %+v, want task placement, workspace data, same release, generation increment", instance)
}
grants, err := store.ListGrants(ctx, "instance-1")
if err != nil {
t.Fatalf("list upgraded grants: %v", err)
}
if len(grants) != 1 || grants[0].ScopeCeiling != ScopeWorkspace || grants[0].ApprovedBy != "owner-1" {
t.Fatalf("upgraded grants = %+v, want one owner-approved workspace task grant", grants)
}
}
func TestVerifyWorkspaceOwnerAllowsAuthDisabledUnownedWorkspace(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
if _, err := store.db.Exec(`CREATE TABLE workspaces (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL DEFAULT '')`); err != nil {
t.Fatalf("create workspaces table: %v", err)
}
if _, err := store.db.Exec(`INSERT INTO workspaces (id, owner_id) VALUES ('workspace-1', '')`); err != nil {
t.Fatalf("insert unowned workspace: %v", err)
}
if err := store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
return verifyWorkspaceOwnerTx(ctx, tx, "workspace-1", userstore.DefaultUserID)
}); err != nil {
t.Fatalf("default user review of unowned workspace: %v", err)
}
if err := store.WithTransaction(ctx, func(tx *sqlx.Tx) error {
return verifyWorkspaceOwnerTx(ctx, tx, "workspace-1", "member-1")
}); !errors.Is(err, ErrWorkspaceOwnerRequired) {
t.Fatalf("ordinary user review of unowned workspace = %v, want ErrWorkspaceOwnerRequired", err)
}
}
func TestCreateInstanceAdmissionIsAtomicAtTaskAndWorkspaceLimits(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
@@ -34,6 +34,7 @@ type CapabilityBinding struct {
WebAppKey string
Placement string
ScopeKind string
DataScopeKind string
WorkspaceID string
TaskID string
SessionID string
@@ -143,7 +143,7 @@ func projectWebAppEvent(source *bus.Event) (webapp.EventInput, bool) {
func isProjectableWebAppEvent(eventType string) bool {
switch eventType {
case events.CanvasCreated, events.CanvasReleaseActivated, events.CanvasReleasePermissionRequired,
events.CanvasPromoted, events.CanvasArchived, events.CanvasRestored, events.CanvasRemoved,
events.CanvasPromoted, events.CanvasWorkspaceDataEnabled, events.CanvasArchived, events.CanvasRestored, events.CanvasRemoved,
events.TaskCreated, events.TaskUpdated, events.TaskStateChanged, events.TaskDeleted, events.TaskMoved,
events.TaskQueuePromoted, events.TaskDependenciesResolved, events.TaskDependencyFailed,
events.WorkspaceCreated, events.WorkspaceUpdated, events.WorkspaceDeleted,
@@ -160,7 +160,7 @@ func isProjectableWebAppEvent(eventType string) bool {
func publicEventFields(eventType string) []string {
canvasFields := []string{
"canvas_id", "workspace_id", "task_id", "scope_kind", "status", "title", "plugin_id", "plugin_instance_id",
"canvas_id", "workspace_id", "task_id", "scope_kind", "data_scope_kind", "status", "title", "plugin_id", "plugin_instance_id",
"release_id", "active_release_id", "validation_status", "validation_error", "source_actor_kind", "source_user_id",
"source_task_id", "source_session_id", "placement", "protocol_version",
}
@@ -234,7 +234,7 @@ func webAppBusEventMatchesInstance(item instances.Instance, scope webapp.EventSc
if scope.WorkspaceID != "" && item.WorkspaceID != scope.WorkspaceID {
return false
}
switch item.ScopeKind {
switch item.EffectiveDataScopeKind() {
case instances.ScopeInstance:
return true
case instances.ScopeWorkspace:
@@ -290,7 +290,7 @@ func webAppEventMatchesBinding(scope webapp.EventScope, binding webapp.Capabilit
if scope.InstanceID != "" && scope.InstanceID != binding.InstanceID {
return false
}
switch binding.ScopeKind {
switch instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) {
case instances.ScopeInstance:
return true
case instances.ScopeWorkspace:
@@ -10,9 +10,11 @@ import (
)
func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) {
event := bus.NewEvent(events.CanvasReleaseActivated, "canvas", map[string]any{
event := bus.NewEvent(events.CanvasWorkspaceDataEnabled, "canvas", map[string]any{
"canvas_id": "canvas-1",
"workspace_id": "workspace-1",
"scope_kind": "task",
"data_scope_kind": "workspace",
"release_id": "release-1",
"active_release_id": "release-1",
"secret": strings.Repeat("s", 128),
@@ -36,6 +38,10 @@ func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) {
if projected.Scope.WorkspaceID != "workspace-1" || projected.Scope.InstanceID != "" {
t.Fatalf("projected scope = %+v", projected.Scope)
}
projectedData, ok := projected.Data.(map[string]any)
if !ok || projectedData["data_scope_kind"] != "workspace" {
t.Fatalf("projected data = %#v, want workspace data scope", projected.Data)
}
}
// TestProjectWebAppEventDropsDependencyProjectionFields covers the refresh
@@ -166,6 +166,7 @@ type webAppContext struct {
WebAppKey string `json:"web_app_key"`
Placement string `json:"placement"`
ScopeKind string `json:"scope_kind"`
DataScopeKind string `json:"data_scope_kind"`
WorkspaceID string `json:"workspace_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
@@ -190,6 +191,7 @@ func (s *Service) handleWebAppContext(w http.ResponseWriter, r *http.Request, bi
WebAppKey: binding.WebAppKey,
Placement: binding.Placement,
ScopeKind: binding.ScopeKind,
DataScopeKind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind),
WorkspaceID: binding.WorkspaceID,
TaskID: binding.TaskID,
SessionID: binding.SessionID,
@@ -364,6 +366,9 @@ func (s *Service) validateWebAppBinding(ctx context.Context, binding webapp.Capa
}
instance, err := store.Get(ctx, binding.InstanceID)
if err != nil || instance.Status != instances.StatusActive || instance.PluginID != binding.PluginID ||
instance.ScopeKind != binding.ScopeKind ||
instance.EffectiveDataScopeKind() != instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) ||
instance.WorkspaceID != binding.WorkspaceID || instance.TaskID != binding.TaskID ||
instance.ActiveReleaseID != binding.ReleaseID || instance.GrantGeneration != binding.GrantGeneration {
return webapp.ErrRuntimeTokenStale
}
@@ -395,13 +400,14 @@ func (s *Service) validateWebAppPermissions(ctx context.Context, binding webapp.
if err != nil {
return err
}
dataScope := instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind)
for _, permission := range webAppDeclaredPermissions(m, binding.WebAppKey) {
if !webAppGrantCovers(permission, binding.ScopeKind, grants) {
if !webAppGrantCovers(permission, dataScope, grants) {
return errors.New("web app permission grant is stale")
}
}
for _, origin := range binding.NetworkOrigins {
if !webAppOriginGranted(origin, binding.ScopeKind, grants) {
if !webAppOriginGranted(origin, dataScope, grants) {
return errors.New("web app network grant is stale")
}
}
@@ -40,7 +40,7 @@ func (s *Service) listWebAppTasks(ctx context.Context, w http.ResponseWriter, r
IncludeArchived: includeArchived,
IncludeEphemeral: true,
}
if binding.ScopeKind == instances.ScopeTask {
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
// Resolved via the non-attaching fetchTask, and dependencies attached
// only below, after the discard checks: matching the attachment rule
// the non-task-scope branch below already documents for itself.
@@ -278,7 +278,7 @@ func (s *Service) listWebAppWorkflows(ctx context.Context, w http.ResponseWriter
}
workflows = append(workflows, items...)
}
if binding.ScopeKind == instances.ScopeTask {
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
if err != nil || task == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
@@ -318,7 +318,7 @@ func (s *Service) listWebAppWorkflowSteps(ctx context.Context, w http.ResponseWr
}
}
}
if binding.ScopeKind == instances.ScopeTask {
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) == instances.ScopeTask {
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
if err != nil || task == nil || task.WorkflowID != workflowID {
writeWebAppError(w, http.StatusNotFound, "not_found")
@@ -380,13 +380,14 @@ func (s *Service) webAppWorkflowWorkspaceIDs(ctx context.Context, r *http.Reques
}
func webAppTaskMatches(ctx context.Context, host *pluginHost, binding webapp.CapabilityBinding, task pluginsdk.Task) bool {
if binding.ScopeKind == instances.ScopeInstance {
dataScope := instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind)
if dataScope == instances.ScopeInstance {
return true
}
if task.WorkspaceID != binding.WorkspaceID {
return false
}
switch binding.ScopeKind {
switch dataScope {
case instances.ScopeWorkspace:
return true
case instances.ScopeTask:
@@ -135,6 +135,47 @@ func TestListWebAppTasks_DerivesDependenciesAfterScopeNarrowing(t *testing.T) {
require.Contains(t, recorder.Body.String(), `"blocked":true`)
}
func TestListWebAppTasks_TaskPlacementUsesWorkspaceDataScopeAndPagination(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}, {ID: "ws-2"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{
"ws-1": {
{ID: "task-a", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)},
{ID: "task-b", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)},
},
"ws-2": {{ID: "foreign", WorkspaceID: "ws-2", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}},
}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeTask, DataScopeKind: instances.ScopeWorkspace,
WorkspaceID: "ws-1", TaskID: "task-a", Permissions: []string{"api_read:tasks"},
}
first := httptest.NewRecorder()
svc.handleWebAppProtocol(first, httptest.NewRequest(http.MethodGet, "/?limit=1", nil), "", binding, "v1/data/tasks")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
var firstPage webAppPage[webAppTask]
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &firstPage))
require.Len(t, firstPage.Items, 1)
require.Equal(t, "task-a", firstPage.Items[0].ID)
require.True(t, firstPage.PageInfo.HasMore)
require.NotEmpty(t, firstPage.PageInfo.NextCursor)
second := httptest.NewRecorder()
secondRequest := httptest.NewRequest(http.MethodGet, "/?limit=1&cursor="+firstPage.PageInfo.NextCursor, nil)
svc.handleWebAppProtocol(second, secondRequest, "", binding, "v1/data/tasks")
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
var secondPage webAppPage[webAppTask]
require.NoError(t, json.Unmarshal(second.Body.Bytes(), &secondPage))
require.Len(t, secondPage.Items, 1)
require.Equal(t, "task-b", secondPage.Items[0].ID)
foreign := httptest.NewRecorder()
foreignRequest := httptest.NewRequest(http.MethodGet, "/?workspace_id=ws-2", nil)
svc.handleWebAppProtocol(foreign, foreignRequest, "", binding, "v1/data/tasks")
require.Equal(t, http.StatusForbidden, foreign.Code)
}
func TestListWebAppTasks_FanOutRefusalBecomesResponseTooLarge(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
@@ -119,7 +119,11 @@ func dependencyRedactionScopeFromBinding(binding webapp.CapabilityBinding, direc
for _, id := range directlyReadableIDs {
ids[id] = struct{}{}
}
return dependencyRedactionScope{kind: binding.ScopeKind, workspaceID: binding.WorkspaceID, directlyReadableIDs: ids}
return dependencyRedactionScope{
kind: instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind),
workspaceID: binding.WorkspaceID,
directlyReadableIDs: ids,
}
}
// admits reports whether ref may be disclosed as a directly readable task
@@ -30,7 +30,7 @@ func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) {
binding := webapp.CapabilityBinding{
UserID: "user-1", InstanceID: "instance-1", PluginID: "plugin-1",
ReleaseID: "release-1", WebAppKey: "board", Placement: "task-canvas",
ScopeKind: "task", WorkspaceID: "workspace-1", TaskID: "task-1",
ScopeKind: "task", DataScopeKind: "workspace", WorkspaceID: "workspace-1", TaskID: "task-1",
Permissions: []string{"api_read:tasks", "state"},
}
request := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -49,7 +49,7 @@ func TestWebAppProtocolContextDoesNotExposeCredentials(t *testing.T) {
if _, ok := body["token"]; ok {
t.Fatal("context exposed token")
}
if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) {
if body["instance_id"] != "instance-1" || body["protocol_version"] != float64(1) || body["scope_kind"] != "task" || body["data_scope_kind"] != "workspace" {
t.Fatalf("context = %#v", body)
}
}
@@ -85,7 +85,7 @@ func (s *Service) listWebAppWorkflowTransitionGroups(ctx context.Context, w http
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
if binding.ScopeKind != instances.ScopeWorkspace || binding.WorkspaceID == "" || !host.capabilities.CanRead(resourceTasks) || !host.capabilities.CanRead(resourceWorkflows) {
if instances.EffectiveDataScopeKind(binding.ScopeKind, binding.DataScopeKind) != instances.ScopeWorkspace || binding.WorkspaceID == "" || !host.capabilities.CanRead(resourceTasks) || !host.capabilities.CanRead(resourceWorkflows) {
writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied")
return
}
@@ -71,14 +71,16 @@ func TestWebAppTransitionGroupsRequireWorkspaceAndBothGrants(t *testing.T) {
return recorder
}
require.Equal(t, http.StatusForbidden, call("wf-1").Code)
binding.ScopeKind = instances.ScopeWorkspace
binding.DataScopeKind = instances.ScopeWorkspace
require.Equal(t, http.StatusOK, call("wf-1").Code)
require.Equal(t, 1, source.groupCalls)
binding.Permissions = []string{"api_read:workflows"}
require.Equal(t, http.StatusForbidden, call("wf-1").Code)
binding.Permissions = []string{"api_read:tasks", "api_read:workflows"}
require.Equal(t, http.StatusNotFound, call("foreign-wf").Code)
require.Zero(t, source.groupCalls)
require.Equal(t, 1, source.groupCalls)
allowed := call("wf-1")
require.Equal(t, http.StatusOK, allowed.Code, allowed.Body.String())
require.Equal(t, 1, source.groupCalls)
require.Equal(t, 2, source.groupCalls)
require.Contains(t, allowed.Body.String(), `"count":2`)
}
@@ -28,7 +28,8 @@ func TestCreateCanvasBuiltin(t *testing.T) {
"source directory",
"authorized live Kandev data",
"awaits permission review",
"requires promotion",
"adds workspace navigation",
"Do not claim that promotion is required to preview",
"local build alone does not publish",
} {
if !strings.Contains(prompt.Content, want) {
+1
View File
@@ -107,6 +107,7 @@ const (
ActionCanvasReleaseActivated = "canvas.release.activated"
ActionCanvasReleasePermissionRequired = "canvas.release.permission_required"
ActionCanvasPromoted = "canvas.promoted"
ActionCanvasWorkspaceDataEnabled = "canvas.workspace_data_enabled"
ActionCanvasArchived = "canvas.archived"
ActionCanvasRestored = "canvas.restored"
ActionCanvasRemoved = "canvas.removed"