Skip to content

fix(api): resolve every secret before writing any of them in PublicSecretsView.put - #1006

Open
omlahore wants to merge 1 commit into
phasehq:mainfrom
omlahore:fix/public-put-preflight
Open

fix(api): resolve every secret before writing any of them in PublicSecretsView.put#1006
omlahore wants to merge 1 commit into
phasehq:mainfrom
omlahore:fix/public-put-preflight

Conversation

@omlahore

@omlahore omlahore commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Follow-up to c192966, which fixed this for the other view.

The problem

E2EESecretsView.put resolves every secret into secret_objects before writing any of them, and the comment says exactly why:

# late foreign, missing, or rotating ID would leave an earlier update committed.

PublicSecretsView.put still saved as it iterated. It calls secret_obj.save(trigger_sync=False) inside the loop, and three separate early returns can fire on a later entry:

  • a missing or soft-deleted id, 404
  • a rotating secret, 400
  • an attempt to unseal a sealed secret, 400

So PUT /v1/secrets with [{good}, {rotating}] answers 400 and the first secret is already written, with nothing telling the caller which part of the batch applied.

Nothing rolls it back. There is no transaction.atomic anywhere in secrets.py, and the surrounding try has only a finally, no except. An early return inside an atomic block would commit anyway, so ordering is the only thing that can make this batch atomic, which is what c192966 established for the E2EE view.

The change

The lookup, the rotating-secret check and the seal-permanence check move into a preflight loop, and the write loop zips over the already-resolved objects. Same shape and same ordering as E2EESecretsView.put.

No behaviour change for a batch that passes validation: same queries, same saves, same order, same response.

Verification

Two tests, mirroring the existing test_rotating_secret_is_rejected_before_any_update for the E2EE view:

  • second entry is a rotating secret, and
  • second entry attempts to unseal a sealed secret

Each asserts the 400, then first.save.assert_not_called(), env.save.assert_not_called() and no audit event.

I checked they fail for the right reason rather than passing by accident. With the view change stashed, both fail on exactly that line and not on the status code, because the endpoint already returned 400 correctly before this change:

        assert response.status_code == status.HTTP_400_BAD_REQUEST     ✓
        assert "Rotating secrets" in ...["error"]                      ✓
>       first.save.assert_not_called()                                 ✗

That is the whole bug: the right answer, after the wrong write.

Full backend suite with the fix: 1505 passed.

Not included

The _resolve_secret_tags error path at the end of the write loop can still return early after earlier saves. It needs an organisation lookup per secret and could be hoisted too, but it changes when tags are resolved relative to encryption, so it is a separate decision rather than something to fold in here.

…cretsView.put

E2EESecretsView.put builds a secret_objects list first and says why: "a late
foreign, missing, or rotating ID would leave an earlier update committed".
PublicSecretsView.put still saved as it iterated, so a batch whose second
entry is soft-deleted, rotating, or attempting to unseal returned 400 or 404
after the first entry had already been written.

There is no transaction.atomic in this view and the surrounding try has only
a finally, so an early return commits rather than rolls back. Ordering is the
only thing that makes the batch atomic, which is what c192966 fixed for the
E2EE view.

The lookup, rotating-secret and seal-permanence checks move into a preflight
loop, and the write loop zips over the resolved objects. No behaviour change
for a batch that passes validation.
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