Skip to content

feat: resource clone & delete with authz hardening#301

Open
ptone wants to merge 5 commits into
GoogleCloudPlatform:mainfrom
ptone:scion/template-harness-refactor
Open

feat: resource clone & delete with authz hardening#301
ptone wants to merge 5 commits into
GoogleCloudPlatform:mainfrom
ptone:scion/template-harness-refactor

Conversation

@ptone
Copy link
Copy Markdown
Member

@ptone ptone commented Jun 4, 2026

Summary

Implements the Resource Clone & Delete feature from the design doc, adding Clone and Delete management actions to the shared resource list component used by both Project Settings and Hub Resources views.

Closes issue 115

Phase 0 — Remove dead Locked field

  • Remove Locked bool from Template and HarnessConfig models, all 16 enforcement sites across 6 handler files, the force query parameter from delete endpoints, and 3 locked-template tests
  • Add DB migration V54 to drop the column

Phase 1 — Backend: harness-config clone, authz, slug uniqueness

  • Add handleHarnessConfigClone (POST /api/v1/harness-configs/{id}/clone) mirroring the template clone endpoint
  • Add CheckAccess authz to deleteTemplateV2, handleTemplateClone, deleteHarnessConfig, and handleHarnessConfigClone — global scope requires hub-admin, project scope checks project capability
  • Add DB migration V55: UNIQUE constraint on (slug, scope, scope_id) for both tables; clone returns 409 Conflict on slug collision
  • Add clone failure cleanup (DeletePrefix orphaned files, no orphaned DB records)
  • Add 6 tests covering clone, cross-scope clone, authz (forbidden/allowed/destination), and slug collision

Phase 2 — Frontend: Clone/Delete row actions, clone-from-global, unified event

  • Add Clone and Delete action menu (sl-dropdown) to each row in <scion-resource-list>
  • Delete confirmation dialog with "Also delete stored files" checkbox (default checked) and irreversible-action warning
  • Clone dialog with name input, prefilled as {name}-copy, 409 collision error surfacing
  • Clone-from-global picker in project settings view (fetches global resources, clones into current project)
  • Unify on resource-changed event with detail.action discriminator (imported | cloned | deleted); migrate import component
  • Gate actions on capabilities: canClone, canDelete, cloneFromGlobal properties wired from host pages

Test plan

  • go build ./... passes
  • go test ./pkg/hub/... ./pkg/store/... — all tests pass including 6 new clone/delete/authz tests
  • TypeScript compiles cleanly (tsc --noEmit)
  • Manual: verify Clone and Delete actions visible in project Resources view for users with appropriate capabilities
  • Manual: verify Clone and Delete actions visible in Hub Resources view (admin only)
  • Manual: verify Clone-from-global picker works in project view
  • Manual: verify delete removes resource and optionally files
  • Manual: verify clone creates copy with new name/slug
  • Manual: verify 409 error shown when cloning with duplicate name
  • Manual: verify non-admin users do not see action buttons

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the Resource Clone and Delete features, enabling users to clone and delete templates and harness configurations across scopes, and adds robust authorization checks to these endpoints. Database migrations were also introduced to remove the unused Locked column and enforce unique slug indexes. The review feedback highlights several critical improvement opportunities: cleaning up copied storage files if database creation fails during cloning to prevent orphaned files, handling local paths on Windows more robustly in name derivation, using idiomatic defer f.Close() inside file upload goroutines, and fixing a copy-paste label bug and redundant cloning method in the frontend resource list component.

Comment on lines +684 to +691
if err := s.store.CreateHarnessConfig(ctx, clone); err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
writeError(w, http.StatusConflict, "conflict", "A resource with this slug already exists in the target scope. Choose a different name.", nil)
return
}
writeErrorFromErr(w, err, "")
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If CreateHarnessConfig fails (e.g., due to a unique constraint violation or database error), the copied files are left in storage, resulting in orphaned files. We should clean up the copied files in storage using stor.DeletePrefix on failure.

	if err := s.store.CreateHarnessConfig(ctx, clone); err != nil {
		if stor != nil {
			_ = stor.DeletePrefix(ctx, storagePath)
		}
		if strings.Contains(err.Error(), "UNIQUE constraint failed") {
			writeError(w, http.StatusConflict, "conflict", "A resource with this slug already exists in the target scope. Choose a different name.", nil)
			return
		}
		writeErrorFromErr(w, err, "")
		return
	}

Comment on lines 830 to 837
if err := s.store.CreateTemplate(ctx, clone); err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
writeError(w, http.StatusConflict, "conflict", "A resource with this slug already exists in the target scope. Choose a different name.", nil)
return
}
writeErrorFromErr(w, err, "")
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If CreateTemplate fails, the copied files are left in storage, resulting in orphaned files. We should clean up the copied files in storage using stor.DeletePrefix on failure.

	if err := s.store.CreateTemplate(ctx, clone); err != nil {
		if stor != nil {
			_ = stor.DeletePrefix(ctx, storagePath)
		}
		if strings.Contains(err.Error(), "UNIQUE constraint failed") {
			writeError(w, http.StatusConflict, "conflict", "A resource with this slug already exists in the target scope. Choose a different name.", nil)
			return
		}
		writeErrorFromErr(w, err, "")
		return
	}

Comment on lines +37 to +69
// rather than the cache-hash directory name.
func DeriveResourceName(source string) string {
source = strings.TrimSpace(source)
if source == "" {
return ""
}

if strings.HasPrefix(source, "file://") {
localPath := strings.TrimPrefix(source, "file://")
return sanitizeDerivedName(filepath.Base(filepath.Clean(localPath)))
}

// rclone connection string, e.g. ":gcs:bucket/path".
if strings.HasPrefix(source, ":") {
parts := strings.SplitN(source, ":", 3)
if len(parts) == 3 {
return sanitizeDerivedName(path.Base(strings.TrimRight(parts[2], "/")))
}
return ""
}

normalized := normalizeResourceSourceURL(source)
u, err := url.Parse(normalized)
if err != nil {
return sanitizeDerivedName(filepath.Base(filepath.Clean(source)))
}

cleanPath := strings.TrimRight(u.Path, "/")
if cleanPath == "" {
return sanitizeDerivedName(filepath.Base(filepath.Clean(source)))
}
return sanitizeDerivedName(path.Base(cleanPath))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For local paths (especially on Windows with backslashes), parsing with url.Parse and then using path.Base can fail to derive the correct name. Since IsRemoteURI is already available in the config package, we can check if the source is a remote URI first. If it is a local path, we can directly clean and extract the base name using filepath.Base and filepath.Clean.

func DeriveResourceName(source string) string {
	source = strings.TrimSpace(source)
	if source == "" {
		return ""
	}

	if !IsRemoteURI(source) {
		if strings.HasPrefix(source, "file://") {
			source = strings.TrimPrefix(source, "file://")
		}
		return sanitizeDerivedName(filepath.Base(filepath.Clean(source)))
	}

	// rclone connection string, e.g. ":gcs:bucket/path".
	if strings.HasPrefix(source, ":") {
		parts := strings.SplitN(source, ":", 3)
		if len(parts) == 3 {
			return sanitizeDerivedName(path.Base(strings.TrimRight(parts[2], "/")))
		}
		return ""
	}

	normalized := normalizeResourceSourceURL(source)
	u, err := url.Parse(normalized)
	if err != nil {
		return sanitizeDerivedName(filepath.Base(filepath.Clean(source)))
	}

	cleanPath := strings.TrimRight(u.Path, "/")
	if cleanPath == "" {
		return sanitizeDerivedName(filepath.Base(filepath.Clean(source)))
	}
	return sanitizeDerivedName(path.Base(cleanPath))
}

Comment on lines +165 to +174
f, err := os.Open(fi.FullPath)
if err != nil {
return fmt.Errorf("%s: failed to open file %s: %w", label, fi.Path, err)
}

_, err = stor.Upload(ctx, objectPath, f, storage.UploadOptions{})
_ = f.Close()
if err != nil {
return fmt.Errorf("%s: failed to upload file %s: %w", label, fi.Path, err)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using defer f.Close() right after successfully opening the file is more idiomatic in Go and ensures the file descriptor is closed robustly even if a panic occurs or if early returns are added in the future.

Suggested change
f, err := os.Open(fi.FullPath)
if err != nil {
return fmt.Errorf("%s: failed to open file %s: %w", label, fi.Path, err)
}
_, err = stor.Upload(ctx, objectPath, f, storage.UploadOptions{})
_ = f.Close()
if err != nil {
return fmt.Errorf("%s: failed to upload file %s: %w", label, fi.Path, err)
}
f, err := os.Open(fi.FullPath)
if err != nil {
return fmt.Errorf("%s: failed to open file %s: %w", label, fi.Path, err)
}
defer f.Close()
_, err = stor.Upload(ctx, objectPath, f, storage.UploadOptions{})
if err != nil {
return fmt.Errorf("%s: failed to upload file %s: %w", label, fi.Path, err)
}

Comment on lines +655 to +703
private renderCloneDialog() {
if (!this.cloneTarget) return nothing;

const isFromGlobal =
this.cloneFromGlobal && this.scope === 'project' && !this.items.find((i) => i.id === this.cloneTarget!.id);

return html`
<sl-dialog
label="Clone ${this.kindLabel}"
open
@sl-request-close=${(e: Event) => {
if (this.actionInProgress) e.preventDefault();
else this.closeCloneDialog();
}}
>
<p>
Clone <strong>${this.cloneTarget.displayName || this.cloneTarget.name}</strong>
${isFromGlobal ? html` from global into this project` : nothing}.
</p>
<sl-input
label="New name"
.value=${this.cloneName}
@sl-input=${(e: Event) => {
this.cloneName = (e.target as HTMLInputElement).value;
}}
></sl-input>
${this.actionError ? html`<div class="dialog-error">${this.actionError}</div>` : nothing}
<div slot="footer">
<sl-button
variant="default"
size="small"
?disabled=${this.actionInProgress}
@click=${() => this.closeCloneDialog()}
>
Cancel
</sl-button>
<sl-button
variant="primary"
size="small"
?loading=${this.actionInProgress}
?disabled=${this.actionInProgress || !this.cloneName.trim()}
@click=${() => (isFromGlobal ? this.confirmCloneFromGlobal() : this.confirmClone())}
>
Clone
</sl-button>
</div>
</sl-dialog>
`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a copy-paste bug in the dialog label: it is set to "Delete ${this.kindLabel}" instead of "Clone ${this.kindLabel}". Additionally, confirmCloneFromGlobal is redundant because confirmClone already uses this.scope and this.scopeId (which are set to 'project' and the project ID on the list component). We can simplify the code by removing confirmCloneFromGlobal and using confirmClone directly.

  private renderCloneDialog() {
    if (!this.cloneTarget) return nothing;

    const isFromGlobal = 
      this.cloneFromGlobal && this.scope === 'project' && !this.items.find((i) => i.id === this.cloneTarget!.id);

    return html`
      <sl-dialog
        label="Clone ${this.kindLabel}"
        open
        @sl-request-close=${(e: Event) => {
          if (this.actionInProgress) e.preventDefault();
          else this.closeCloneDialog();
        }}
      >
        <p>
          Clone <strong>${this.cloneTarget.displayName || this.cloneTarget.name}</strong>
          ${isFromGlobal ? html` from global into this project` : nothing}.
        </p>
        <sl-input
          label="New name"
          .value=${this.cloneName}
          @sl-input=${(e: Event) => {
            this.cloneName = (e.target as HTMLInputElement).value;
          }}
        ></sl-input>
        ${this.actionError ? html`<div class="dialog-error">${this.actionError}</div>` : nothing}
        <div slot="footer">
          <sl-button
            variant="default"
            size="small"
            ?disabled=${this.actionInProgress}
            @click=${() => this.closeCloneDialog()}
          >
            Cancel
          </sl-button>
          <sl-button
            variant="primary"
            size="small"
            ?loading=${this.actionInProgress}
            ?disabled=${this.actionInProgress || !this.cloneName.trim()}
            @click=${() => this.confirmClone()}
          >
            Clone
          </sl-button>
        </div>
      </sl-dialog>
    `;
  }

Comment on lines +454 to +493
private async confirmCloneFromGlobal(): Promise<void> {
if (!this.cloneTarget) return;
this.actionInProgress = true;
this.actionError = '';
try {
const body: Record<string, string> = {
name: this.cloneName,
scope: 'project',
scopeId: this.scopeId,
};

const response = await apiFetch(
`/api/v1/${this.apiResource}/${this.cloneTarget.id}/clone`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (response.status === 409) {
this.actionError = 'A resource with this slug already exists. Choose a different name.';
this.actionInProgress = false;
return;
}
if (!response.ok) {
throw new Error(
await extractApiError(response, `Failed to clone: HTTP ${response.status}`)
);
}
const created = (await response.json()) as { id?: string };
const sourceId = this.cloneTarget.id;
this.closeCloneDialog();
await this.load();
this.emitChanged('cloned', created.id ?? '', sourceId);
} catch (err) {
this.actionError = err instanceof Error ? err.message : 'Clone failed';
} finally {
this.actionInProgress = false;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This method is redundant and can be removed because confirmClone already handles cloning into the current project scope.

ptone added 5 commits June 4, 2026 01:44
…dels

Remove the Locked bool field, all 16 enforcement sites across 6 handler
files, the force query parameter from delete endpoints, 3 locked-template
tests, and add a DB migration to drop the column. No production code ever
set Locked=true — this simplifies the handlers for the upcoming clone/delete
feature.
…iqueness

- Add handleHarnessConfigClone mirroring template clone
- Add CheckAccess authz to deleteTemplateV2, handleTemplateClone, deleteHarnessConfig, handleHarnessConfigClone
- Add DB migration V55: UNIQUE constraint on (slug, scope, scope_id)
- Return 409 Conflict on slug collision during clone
- Add clone failure cleanup
- Add tests for clone, authz, and slug collision
…urce list

- Add Clone and Delete action menu to shared resource-list component
- Add delete confirmation dialog with deleteFiles checkbox (default on)
- Add clone dialog with name input and 409 collision handling
- Add clone-from-global picker in project settings view
- Unify on resource-changed event (migrate resource-imported)
- Gate actions on capabilities (canClone, canDelete properties)
… remove redundant clone method

- Add stor.DeletePrefix cleanup when CreateTemplate/CreateHarnessConfig fails
  after files were already copied (prevents orphaned storage files)
- Remove redundant confirmCloneFromGlobal method — confirmClone already
  handles cross-scope clone via the component's scope/scopeId properties
@scion-gteam scion-gteam Bot force-pushed the scion/template-harness-refactor branch from ad9761a to a5064db Compare June 4, 2026 01:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant