feat: preview task canvases with workspace data (#3887)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤t, 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, ¤t, 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, ¤t, 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, ¤t, 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, ¤t, 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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user