Skip to content

fix(auth): GC stale FactorLookup when metadata does not authorize - #238

Open
orekav wants to merge 12 commits into
mainfrom
uaf/1b-auth-gc-stale-factor-lookup
Open

fix(auth): GC stale FactorLookup when metadata does not authorize#238
orekav wants to merge 12 commits into
mainfrom
uaf/1b-auth-gc-stale-factor-lookup

Conversation

@orekav

@orekav orekav commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Follow-up to Paolo’s simpler consistency model (after fix(storage): factor write reconcile + consistent lookup #231 dropped put-outcome reconcile): orphan Dynamo index rows are cleaned up at authentication time.
  • When passkey / OIDC / EC auth finds a FactorLookup row but S3 metadata is missing or does not contain that factor, delete the lookup (best-effort) before returning BackupMissing / UnauthorizedFactor.
  • Does not delete lookups on ambiguous write failures; aligns with “never remove Dynamo until we know S3 no longer authorizes.”

Stack

Base: #231 (uaf/1-factor-write-reconcile)

Test plan

  • Existing auth / add-factor / delete-factor tests pass
  • Manual: leave a stale Dynamo row for a deleted factor → next auth with that factor returns unauthorized/missing and removes the row
  • Happy-path auth unchanged (authorized factor still succeeds; lookup retained)

orekav added 7 commits August 6, 2026 15:41
Distinguish own factor-id retries from concurrent same-identity winners
on ambiguous S3 puts, add exact-match key-only upgrades with ambiguous
reconcile, and expose consistent DynamoDB factor lookup after races.
Trim redundant commentary and correct resolve_put_object_outcome docs so
Inserted requires a matching factor id for every ambiguous put error.
Keep simple classify_put_object_error (NotInserted vs Unknown) and stop
re-reading metadata after 412/timeout/5xx. Ambiguous failures keep the
FactorLookup; orphans can be GC'd at auth time.
On authentication, if Dynamo maps a factor to a backup but S3 metadata is
missing or does not contain that factor, delete the lookup row (best-effort)
before returning BackupMissing / UnauthorizedFactor.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9db8e8fbc1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/auth.rs
/// Deletes a `FactorLookup` row that pointed at a backup where the factor is no longer present
/// (or the backup is gone). Best-effort: auth still fails; delete errors are logged only.
async fn gc_stale_factor_lookup(&self, scope: FactorScope, factor: &FactorToLookup) {
match self.factor_lookup.delete(scope, factor).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid deleting lookups while metadata writes are in flight

The cleanup assumes that missing or unauthorized metadata proves the lookup is stale, but the write flows publish lookup rows first: for example, src/routes/create_backup.rs:164-180 inserts both mappings before creating the S3 object, and the add-factor flows use the same ordering. Because authentication does not share the create/add lock, a concurrent request can observe the new lookup during that window, reach this unconditional delete, and then have the metadata write succeed, leaving a valid backup or factor permanently untraceable. Coordinate cleanup with these writers or distinguish pending lookup rows before deleting them.

Useful? React with 👍 / 👎.

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.

a simple lock could resolve this

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.

Implemented a factor-scoped Redis mutate lock (Paolo’s suggestion):

  • create_backup / add_factor / add_sync_factor hold factor_lookup_mutate:{scope}#{factor_pk} from before FactorLookup insert until after the S3 metadata write (and any rollback).
  • Auth stale-delete acquires the same lock; if it’s held (or Redis fails), we skip the delete and leave the row for a later attempt.

Pushed in the latest commit on this PR.

orekav and others added 2 commits August 7, 2026 17:31
Stop early only after seeing lengths 70/71/72, not after any three
lengths (which could include rare 69 and miss 71).
Comment thread src/auth.rs Outdated

/// Deletes a `FactorLookup` row that pointed at a backup where the factor is no longer present
/// (or the backup is gone). Best-effort: auth still fails; delete errors are logged only.
async fn gc_stale_factor_lookup(&self, scope: FactorScope, factor: &FactorToLookup) {

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.

dumb question what does GC stand for?

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.

Garbage collection — cleaning up a stale Dynamo index row once we know S3 metadata no longer authorizes that factor.

Renamed to delete_stale_factor_lookup in 70e38bf so it’s clearer.

paolodamico
paolodamico previously approved these changes Aug 7, 2026
Base automatically changed from uaf/1-factor-write-reconcile to main August 7, 2026 19:03
@orekav
orekav dismissed paolodamico’s stale review August 7, 2026 19:04

The base branch was changed.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 70e38bf8b5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/auth.rs
/// Deletes a `FactorLookup` row that pointed at a backup where the factor is no longer present
/// (or the backup is gone). Best-effort: auth still fails; delete errors are logged only.
async fn delete_stale_factor_lookup(&self, scope: FactorScope, factor: &FactorToLookup) {
match self.factor_lookup.delete(scope, factor).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Delete only the lookup version that was inspected

When a stale lookup for a factor is removed and the factor is registered to another backup after this request's lookup() but before this call, the unconditional key-only delete removes the newly inserted mapping rather than the stale mapping that produced not_verified_backup_id. This is especially possible because authentication uses an eventually consistent read; the new registration can complete successfully while its factor becomes permanently untraceable. Pass the observed backup ID into this helper and make the DynamoDB deletion conditional on BackupId still matching it.

Useful? React with 👍 / 👎.

Writers hold a factor-scoped Redis lock from lookup insert through the
metadata put. Auth stale-delete skips when that lock is held so it cannot
remove an in-flight mapping before S3 catches up.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c77cdfc04

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/auth.rs
}
};

match self.factor_lookup.delete(scope, factor).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Delete only the lookup mapping that was inspected

When delete_factor removes the old mapping and the same factor is registered to another backup after this request's initial lookup, acquiring the new per-factor lock only waits for that registration to finish; this key-only delete then removes the replacement mapping. Fresh evidence in this revision is that the helper still calls delete(scope, factor) without passing the observed not_verified_backup_id, even though writers now release this same lock after committing new metadata. Make the DynamoDB deletion conditional on BackupId still matching the ID observed by authentication.

Useful? React with 👍 / 👎.

factor_lookup_mutate_lock_id(FactorScope::Sync, &sync_factor_to_lookup),
Some(FACTOR_LOOKUP_MUTATE_LOCK_TTL_SECS),
)
.await?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore the sync token when lock acquisition fails

When this factor's mutate lock is briefly held by stale cleanup or another writer, this ? returns Locked after use_sync_factor_token has atomically marked the one-time token as used. This path never calls unuse_sync_factor_token, so retrying gets AlreadyUsed even though no lookup or metadata was written; acquire the lock before consuming the token or restore the token on acquisition failure.

Useful? React with 👍 / 👎.

Comment on lines +202 to +203
let _ = main_factor_lock.release().await;
let _ = sync_factor_lock.release().await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep create locks held through lookup rollback

On a failed create, releasing these locks before deleting the inserted rows leaves the rollback outside the coordination protocol. A concurrent authentication can delete the stale row, another create or add-factor operation can then register the same factor to a valid backup, and the original request's subsequent unconditional rollback deletes that new mapping; release each mutate lock only after its corresponding failure cleanup completes.

Useful? React with 👍 / 👎.

Comment thread src/auth.rs
Comment on lines +660 to +664
.try_acquire_lock_guard(
FACTOR_LOOKUP_MUTATE_LOCK_PREFIX,
factor_lookup_mutate_lock_id(scope, factor),
Some(FACTOR_LOOKUP_MUTATE_LOCK_TTL_SECS),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recheck metadata after acquiring the mutate lock

When authentication reads a newly inserted DynamoDB lookup while its writer still holds the lock, the S3 read can report missing or unauthorized metadata; if the writer commits and releases before this helper runs, the helper acquires the now-free lock and deletes the valid mapping. Fresh evidence in this revision is that lock acquisition occurs only after the caller has made its stale-metadata decision, so checking whether the lock is currently held does not order that metadata read against the write. Acquire the lock before inspecting metadata or re-read the lookup and metadata under the lock before deleting.

Useful? React with 👍 / 👎.

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.

2 participants