Skip to content

HYPERFLEET-1155 - feat: add force-delete endpoint for generic resources#266

Open
kuudori wants to merge 1 commit into
openshift-hyperfleet:mainfrom
kuudori:HYPERFLEET-1155-feat-force-delete-generic-resources
Open

HYPERFLEET-1155 - feat: add force-delete endpoint for generic resources#266
kuudori wants to merge 1 commit into
openshift-hyperfleet:mainfrom
kuudori:HYPERFLEET-1155-feat-force-delete-generic-resources

Conversation

@kuudori

@kuudori kuudori commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add POST /{plural}/{id}/force-delete to ResourceHandler for all generic entity types (channels, versions, wifconfigs)
  • Bypasses OnParentDelete policies, recursively hard-deletes entire ownership tree for resources in Finalizing state
  • Fail-loud guard + tripwire test for RequiredAdapters invariant (HYPERFLEET-1154)

Design

Per ADR-0013 (DB-only force-delete) and ADR-0012 (bottom-up deletion ordering):

  • Finalizing gate: resource must have deleted_time IS NOT NULL (409 otherwise)
  • Reason required: validated via validateNotEmpty + validateMaxLength
  • Recursive cascade: forceDeleteResourceTree walks all children regardless of OnParentDelete policy, hard-deletes bottom-up
  • Atomicity: transaction middleware wraps POST; MarkForRollback on any error prevents partial commits
  • Audit: structured Info log per resource with kind, id, caller, reason, child IDs
  • RequiredAdapters tripwire: runtime error if non-empty + TestAllGenericDescriptors_HaveNoRequiredAdapters breaks CI when assumption changes

Test plan

  • Unit tests: handler (ForceDelete, ForceDeleteByOwner — 204/400/404/409/500), service (happy path, cascade, restrict bypass, grandchildren, RequiredAdapters guard, invalid kind)
  • Tripwire test: TestAllGenericDescriptors_HaveNoRequiredAdapters
  • Integration tests: cascade with restricted children, no-children channel, 409 not-finalizing, 404 not-found, nested version force-delete
  • make verify-all passes (1322 tests)
  • make test-integration passes (force-delete scenarios)

@openshift-ci openshift-ci Bot requested review from Mischulee and rafabene June 30, 2026 20:52
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added Force Delete for resources with both direct and parent-scoped routes.
    • Operation supports cascading hard deletion of related child resources when the target is in a finalizing state.
  • Bug Fixes
    • Enforced non-empty reason validation (including max length) for force-delete.
    • Improved safety checks for missing resources, unexpected states (returns 409), and blocked resources (returns 409/500).
  • Tests
    • Expanded handler/service/integration coverage for force-delete success, failures, and nested scenarios.

Walkthrough

Adds force-delete handling for resources, with handler routing now branching on parent_id and a new ForceDelete endpoint that validates reason before calling the service. ResourceService.ForceDelete enforces finalizing-state checks, rejects resources with RequiredAdapters, and recursively hard-deletes descendants. Tests cover handler, service, and integration paths for success, conflicts, not-found, and adapter-blocked cases.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ResourceHandler
  participant sqlResourceService
  participant resourceDao
  participant registry

  Client->>ResourceHandler: POST /.../force-delete {reason}
  ResourceHandler->>ResourceHandler: validate reason
  ResourceHandler->>ResourceHandler: verifyOwnership if parent_id present
  ResourceHandler->>sqlResourceService: ForceDelete(kind, id, reason)
  sqlResourceService->>resourceDao: GetForUpdate(kind, id)
  sqlResourceService->>registry: ChildrenOf(kind)
  sqlResourceService->>resourceDao: FindByKindAndOwnerForUpdate(...)
  sqlResourceService->>resourceDao: Delete(resource)
  sqlResourceService-->>ResourceHandler: success or ServiceError
  ResourceHandler-->>Client: 204 or mapped error
Loading

Related PRs: None provided.

Suggested labels: enhancement, security

Suggested reviewers: None provided.

CWE-284: ownership checks in verifyOwnership gate nested force-delete routes; verify route-variable handling cannot be bypassed.

CWE-863: destructive hard-delete flow depends on upstream authorization; confirm policy enforcement on the new routes.

CWE-362: recursive locking across parent/child force-delete paths should be checked for deadlock and race conditions.

🚥 Pre-merge checks | ✅ 9 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.70% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
No Pii Or Sensitive Data In Logs ⚠️ Warning ForceDelete logs caller and raw reason; caller comes from auth.GetUsernameFromContext and can be an email, so this is CWE-532. Remove or redact caller/reason from the audit log, or hash/tokenize them before logging.
✅ Passed checks (9 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: adding a force-delete endpoint for generic resources.
Description check ✅ Passed The description matches the implemented force-delete endpoint, recursive hard-delete behavior, validation, and tests.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Sec-02: Secrets In Log Output ✅ Passed No CWE-532 issue found: new non-test logs use resource_id/child_kind/caller/reason, with no token/password/credential/secret fields or interpolations.
No Hardcoded Secrets ✅ Passed No hardcoded secrets found in changed files: no apiKey/secret/token/password literals, embedded-credential URLs, private keys, or secret-like base64 blobs (CWE-259/CWE-798).
No Weak Cryptography ✅ Passed No crypto/md5/des/rc4, SHA1 security use, ECB, custom crypto, or secret comparisons; no CWE-327/CWE-208 weakness found in touched code.
No Injection Vectors ✅ Passed PASS: The new force-delete path uses validated kinds and parameterized DAO calls; no new SQL/exec/template/yaml sinks were added (CWE-89/78/79/502).
No Privileged Containers ✅ Passed Only Go files/tests changed; no manifests/templates/Dockerfiles, so no privileged settings (CWE-250/284) are introduced.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

Comment @coderabbitai help to get the list of available commands.

@hyperfleet-ci-bot

hyperfleet-ci-bot Bot commented Jun 30, 2026

Copy link
Copy Markdown

Risk Score: 3 — risk/medium

Signal Detail Points
PR size 1019 lines (>500) +2
Sensitive paths none +0
Test coverage Missing tests for: plugins/entities +1

Computed by hyperfleet-risk-scorer

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/services/resource_test.go`:
- Around line 1318-1328: The tripwire test is only validating local fixtures
because setupTestDescriptors() rebuilds the registry, so it never checks real
plugin descriptors. Move the RequiredAdapters assertion out of
TestAllGenericDescriptors_HaveNoRequiredAdapters and into a test that loads the
actual generic-resource plugins, then iterate registry.All() to verify the
registered descriptors from the real registry. Keep the existing Expect check
and failure message, but ensure it runs against the production plugin
registration path rather than the test fixture registry.

In `@test/integration/resource_force_delete_test.go`:
- Around line 110-139: The NestedVersionForceDelete subtest in the resource
force-delete integration test only verifies that the Version identified by
versionID is removed, but it does not confirm that the parent channel remains
intact. Update this test around the existing ForceDelete and checkResourceCount
assertions to also assert that channel.ID still exists after the nested version
delete, using the existing helpers in setupResourceTest, createChannel, and
checkResourceCount so the parent scope is explicitly validated.
- Around line 22-141: The current `TestResourceForceDelete` suite only calls
`svc.ForceDelete`, so it misses regressions in
`pkg/handlers/resource_handler.go` and the route/plugin wiring for `POST
/{plural}/{id}/force-delete` and the nested owner-scoped force-delete path.
Update these subtests to use the integration HTTP setup from
`test.RegisterIntegration(t)` (helper, client) with Resty and Gomega, send real
requests through the handler routes, and assert on response status/body plus
`reason` validation instead of only checking `ServiceError.HTTPCode`. Keep the
DB cleanup assertions, but locate the tests by `TestResourceForceDelete`,
`setupResourceTest`, and `ForceDelete` when refactoring.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: cd5c2875-03f9-424c-a4fe-6dd27eb0ccdd

📥 Commits

Reviewing files that changed from the base of the PR and between 8f20481 and 8779683.

📒 Files selected for processing (8)
  • pkg/handlers/resource_handler.go
  • pkg/handlers/resource_handler_test.go
  • pkg/services/resource.go
  • pkg/services/resource_test.go
  • plugins/channels/plugin.go
  • plugins/versions/plugin.go
  • plugins/wifconfigs/plugin.go
  • test/integration/resource_force_delete_test.go
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • openshift-hyperfleet/architecture (manual)
  • openshift-hyperfleet/hyperfleet-api (manual)
  • openshift-hyperfleet/hyperfleet-sentinel (manual)
  • openshift-hyperfleet/hyperfleet-adapter (manual)
  • openshift-hyperfleet/hyperfleet-broker (manual)

Comment thread pkg/services/resource_test.go
Comment thread test/integration/resource_force_delete_test.go
Comment thread test/integration/resource_force_delete_test.go
@kuudori kuudori force-pushed the HYPERFLEET-1155-feat-force-delete-generic-resources branch from 8779683 to c0d96a5 Compare June 30, 2026 21:10
Comment thread test/integration/resource_force_delete_test.go
Comment thread pkg/services/resource.go
Comment thread pkg/services/resource.go
Comment thread test/integration/resource_force_delete_test.go Outdated
@pnguyen44

Copy link
Copy Markdown
Contributor

JIRA description says "soft-deleting" but per ADR-0013 the intent is hard-delete. Worth updating the ticket wording to avoid future confusion.

@kuudori kuudori force-pushed the HYPERFLEET-1155-feat-force-delete-generic-resources branch from c0d96a5 to 1c2f75c Compare July 1, 2026 20:22
Comment thread pkg/handlers/resource_handler.go Outdated
Comment on lines +310 to +316
if _, err := h.service.GetByOwner(r.Context(), h.descriptor.Kind, id, parentID); err != nil {
return nil, err
}

if err := h.service.ForceDelete(r.Context(), h.descriptor.Kind, id, req.Reason); err != nil {
return nil, 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.

nit: Wdyt about creating a ForceDeleteByOwner(ctx, kind, id, parentID, reason) for this one?
The two-call approach works correctly (no TOCTOU — ForceDelete re-locks via GetForUpdate), but breaks the handler's "validate request, delegate once" pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'd stick with the current implementation. The handler just calls the service layer, no additional logic 🙂
A dedicated service method would also create drift if the delete flow changes later...

Comment thread test/integration/resource_force_delete_test.go
Comment thread plugins/wifconfigs/plugin.go Outdated
@kuudori kuudori force-pushed the HYPERFLEET-1155-feat-force-delete-generic-resources branch from 1c2f75c to 8da9e50 Compare July 2, 2026 16:14
@kuudori kuudori force-pushed the HYPERFLEET-1155-feat-force-delete-generic-resources branch from 8da9e50 to 9a28571 Compare July 2, 2026 16:17
@kuudori

kuudori commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

/test unit

@kuudori

kuudori commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

/test presubmits-integration

Comment thread plugins/entities/plugin.go Outdated
Comment on lines +48 to +57
@@ -53,6 +54,7 @@ func RegisterEntityRoutes(apiV1Router *mux.Router, resourceService services.Reso
pr.HandleFunc("/{id}", h.GetByOwner).Methods(http.MethodGet)
pr.HandleFunc("/{id}", h.PatchByOwner).Methods(http.MethodPatch)
pr.HandleFunc("/{id}", h.DeleteByOwner).Methods(http.MethodDelete)
pr.HandleFunc("/{id}/force-delete", h.ForceDeleteByOwner).Methods(http.MethodPost)

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.

These ByOwner methods start looking a bit suspicious to me.

Is the only difference that they query adding AND owner_id=?

Is that protection really needed on read?
My rationale:

  • If we make sure that the write path can not store a child item without its proper parent...
  • Do we need to check for every child read, that it belongs to a parent?

Add POST /{plural}/{id}/force-delete to ResourceHandler for all generic
entity types (channels, versions, wifconfigs). The endpoint bypasses
OnParentDelete policies and recursively hard-deletes the entire ownership
tree for resources in Finalizing state (deleted_time IS NOT NULL).

Key design decisions per ADR-0013 (DB-only force-delete):
- Requires Finalizing state and a reason field in the request body
- Bottom-up hard-delete ordering per ADR-0012
- Fail-loud guard if RequiredAdapters non-empty (tripwire for HYPERFLEET-1154)
- MarkForRollback on error prevents partial tree deletion from committing
- Audit log with structured fields before each resource deletion

Includes unit tests (handler + service + tripwire) and integration tests
confirming force-delete succeeds where normal DELETE returns 409 due to
Restrict policy.

Also collapses the flat/owner-nested method pairs on ResourceHandler
(Create/CreateWithOwner, Get/GetByOwner, List/ListByOwner, Patch/PatchByOwner,
Delete/DeleteByOwner, ForceDelete/ForceDeleteByOwner) into six methods that
branch on parent_id presence in mux.Vars(r). The split pairs were identical
logic with a parent-check bolted on the front — exactly the shape where a
fix lands in one and is forgotten in the other. Route registration in
plugins/entities/plugin.go collapses the same way: flat and nested routes
now share one prefix-builder and one set of HandleFunc calls instead of two
duplicated blocks.

Patch/Delete/ForceDelete share a verifyOwnership helper (parent-check only,
no parent data needed). Create/Get/List keep the branch inline since they
use the parent's ID/Kind/Href or a different *ByOwner service call — the two
shapes aren't unifiable under one helper without leaking either interface.

The owner_id predicate in GetByOwner/PatchByOwner-style checks stays: the
write path guarantees a child has a valid parent (referential integrity),
but not that the URL's parent matches the URL's child (request scoping).
Without the check, GET /parents/B/children/X returns X even if X actually
belongs to parent A — wrong-404 today, an IDOR the moment any authorization
is scoped by parent. Service-layer Get/GetByOwner and List/ListByOwner stay
separate rather than merging into an ownerID-optional signature — an empty
string standing in for "unscoped" is a stringly-typed sentinel on a
security-relevant filter, indistinguishable from a caller that forgot to
populate it.
@kuudori kuudori force-pushed the HYPERFLEET-1155-feat-force-delete-generic-resources branch from 9a28571 to 7fe740f Compare July 6, 2026 18:28
@openshift-ci

openshift-ci Bot commented Jul 6, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign sherine-k for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
pkg/handlers/resource_handler.go (1)

49-66: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Convert the documented silent-misroute risk into a fail-loud guard.

The comment at Lines 16-23 concedes that if the registration invariant is ever bypassed (a nested ParentKind != "" descriptor wired to a flat route), Create silently skips owner references instead of erroring. That failure mode is a broken owner-scoping / authorization gap (CWE-863): a child persisted with no OwnerID escapes every subsequent GetByOwner/verifyOwnership boundary. Relying only on cross-file wiring in plugins/entities/plugin.go is fragile — a single asserted check makes the mismatch loud.

Proposed guard (illustrative)
// ownerScope resolves parent_id and enforces that presence matches the descriptor.
func (h *ResourceHandler) ownerScope(r *http.Request) (parentID string, nested bool, err *errors.ServiceError) {
	parentID, present := mux.Vars(r)["parent_id"]
	if present != (h.descriptor.ParentKind != "") {
		return "", false, errors.GeneralError(
			"route/descriptor mismatch for kind %q: parent_id present=%v, ParentKind=%q",
			h.descriptor.Kind, present, h.descriptor.ParentKind)
	}
	return parentID, present, nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/handlers/resource_handler.go` around lines 49 - 66, Add a fail-loud guard
in ResourceHandler.Create so route ownership must match the descriptor’s nesting
rules: if mux.Vars(r) contains parent_id, h.descriptor.ParentKind must be set,
and if it does not, h.descriptor.ParentKind must be empty. Factor this check
into a small helper near Create (for example, an ownerScope-style helper) and
return a GeneralError on mismatch before calling presenters.ConvertResource /
ConvertResourceWithOwner, so a miswired nested descriptor on a flat route cannot
silently skip OwnerID assignment.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@pkg/handlers/resource_handler.go`:
- Around line 49-66: Add a fail-loud guard in ResourceHandler.Create so route
ownership must match the descriptor’s nesting rules: if mux.Vars(r) contains
parent_id, h.descriptor.ParentKind must be set, and if it does not,
h.descriptor.ParentKind must be empty. Factor this check into a small helper
near Create (for example, an ownerScope-style helper) and return a GeneralError
on mismatch before calling presenters.ConvertResource /
ConvertResourceWithOwner, so a miswired nested descriptor on a flat route cannot
silently skip OwnerID assignment.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Central YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Enterprise

Run ID: 85c51194-7193-4532-a32c-333389b2c4cb

📥 Commits

Reviewing files that changed from the base of the PR and between 9a28571 and 7fe740f.

📒 Files selected for processing (7)
  • pkg/handlers/resource_handler.go
  • pkg/handlers/resource_handler_test.go
  • pkg/services/resource.go
  • pkg/services/resource_test.go
  • plugins/entities/plugin.go
  • test/integration/resource_force_delete_test.go
  • test/integration/resource_helpers.go
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • openshift-hyperfleet/architecture (manual)
  • openshift-hyperfleet/hyperfleet-api (manual)
  • openshift-hyperfleet/hyperfleet-sentinel (manual)
  • openshift-hyperfleet/hyperfleet-adapter (manual)
  • openshift-hyperfleet/hyperfleet-broker (manual)
🚧 Files skipped from review as they are similar to previous changes (4)
  • test/integration/resource_helpers.go
  • test/integration/resource_force_delete_test.go
  • pkg/services/resource.go
  • pkg/services/resource_test.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants