Repository navigation
refactor(access): put the operator verbs behind IAccessOperations and the purge behind IRefreshTokenPurge - #1047
Conversation
… purge batch bound provision-account's success line was checked with Contains(email), which a result that swapped the farm code and the owner email still satisfied; the new test requires the exact line with the committed account id. No CLI test passed recover-admin --account: the new one gives one email an Owner in two farms and requires only the named farm's user to be reset and audited with the operator's reason. RefreshTokenPurgeSweepTests checks that every aged row goes, which an unbounded DELETE also satisfies; the new test checks that no purge statement removes more than one batch. Refs #857
… the purge behind IRefreshTokenPurge The six account verbs (bootstrap-admin, recover-admin, provision-account, suspend-account, reactivate-account, rename-account) now resolve IAccessOperations instead of the five Identity services. Each member forwards to the same service; provision-account maps AccountProvisionOutcome onto ProvisionedAccountDetails, field for field. The four outcome records move unchanged into Application.Features.Users so the contract can name them. RefreshTokenPurgeSweep keeps its cutoff, grace, batch size, batch cap and error containment, and calls IRefreshTokenPurge for the batched DELETE, which moves verbatim. The sweep gains an adapter row reaching Access, the tenant-bypass row follows the query, and the matrix is regenerated. Refs #857
|
Independent security + code-quality review (Claude Opus 5.5, high: security-review, thermo-nuclear, ponytail) at head PR #1047 (#857 PR D): independent security review, OpusHead Bottom lineNo product defect and no security defect. Every verb forwards to the same service with the same arguments, the same token and the same output. The purge runs the same SQL under the same bound. One operational change is not stated in the PR's "Nothing changes for operators": FindingsF1 — P3 —
|
| tree | Redis blank | Redis black-holed |
|---|---|---|
base 75e2b1be src |
2,530 ms, exit 0 | 2,491 ms, exit 0 |
head 810fb67d src |
2,532 ms, exit 0 | 13,082 ms, exit 0 |
Output is in redis-probe-base.txt and redis-probe-head.txt. Stdout and stderr match in kind, and stderr is empty in all four runs.
Not a security defect. Nothing fails open. The suspension, revocation and audit run unchanged, and the extra closure is only constructed, never invoked. bootstrap-admin and recover-admin already carried this closure before the PR, so refactor(api): make process role explicit for boot guards #347's minimal-config posture holds; OneShotVerbMinimalConfigTests constructs it under minimal configuration.
Fix, optional. Split the port by dependency closure: one port for suspend, reactivate and rename, one for bootstrap, recover and provision (thermo T1). Alternatively, record the cost in the PR body and stop claiming "nothing changes for operators".
F2 — P3 — src/Cluckwork.Application/Features/Users/AccessOperationOutcomes.cs:192-196 — ProvisionedAccountDetails is a duplicate, exact today — CONFIRMED (exact copy)
- Defect. None today. The record matches
AccountProvisionOutcome(AccountProvisioner.cs:204-208) field for field:Guid AccountId, string Slug, string OwnerEmail, string TemporaryPassword, with the same order, names and nullability. The copy atAccessOperations.cs:277maps by name. - Risk. It is the only hand-written mapping in the slice, and it carries the generated password. A future field added to one record and not the other drifts silently.
- Evidence. Mutant D9 (swap slug and email) and my O1 (
TemporaryPassword := OwnerEmail) both go red; see below. - Fix, optional. Move
AccountProvisionOutcometo Application like the other four records. That needs oneusingline each inAccountProvisioningTests.csandTwoFarmIsolationMatrixTests.cs, and lets the method forward in one line.
No other finding. Every question in the brief is answered below.
Adversarial checklist
| Question | Answer | Basis |
|---|---|---|
| A generated password leaks beyond stdout? | No. The password still reaches only Console.Out in the three verbs. AccessOperations does not log, and the result is never passed to a logger. The record's synthesized ToString includes the password, but so did the original's, and nothing formats either one. |
Read all six verbs and AccessOperations |
| Recover, provision or rename in the wrong farm? | No. accountId, slug, currentSlug and newSlug are forwarded positionally, with the same parameter order as the service signatures. Recovery with --account resets only farm B; mutant D7 is red. |
Code, D7, the new two-farm test |
| Suspend without same-transaction revocation and epoch bump? | No. SuspendAccountAsync → suspension.SuspendAsync, and the service is untouched apart from deleting the record. The verb passes the same accountId and reason. |
Diff of AccountSuspensionService.cs is record deletion and one using |
| Audit row with the wrong actor or reason? | No. Actor resolution lives in the services, which are unchanged. The reason is forwarded for all four verbs that take one. The new test pins two-farm drill on farm B only. |
Code; PR mutants D3–D5 and D8 |
| Exit codes or output changed? | No. Every message, branch and return in the verbs is untouched. The provision-account exact-line test passes at the head. |
Diff and baseline run |
Rename's slug + Version comparison broken? |
No. AccountRenameService keeps its body; only the record moved. |
Diff |
| Process-role scoping escaped? | No. Both ports register in AddCluckworkIdentity beside the five services, which run for both roles, and no new guard or config read was added. F1 is the only closure change, and that closure was already proven buildable under minimal configuration. |
CluckworkIdentityServiceCollectionExtensions.cs:131-143 |
| Purge SQL, bound, grace, errors, single leader unchanged? | Yes, unchanged. It is the same LINQ chain with Take(batchSize) taking the sweep's constant 500. PurgeGrace, MaxBatchesPerRun, the loop and the try/catch stay in the sweep. The scope and port are resolved where AppDbContext was resolved before, also outside the try, so failure behaviour is unchanged. The only caller is still DurableJobWorker under the leader gate. |
Code; D15 and O2 |
| Captive dependency? | No. AccessOperations and RefreshTokenPurge are scoped over scoped dependencies. The singleton sweep resolves the port from its own CreateScope(). The verbs resolve from a created scope; bootstrap-admin and recover-admin use scope.ServiceProvider. The only singleton in the new closure is IConnectionMultiplexer/IClaimOnceStore, consumed by scoped services, which is legal. |
Code |
ProvisionedAccountDetails an exact copy? |
Yes. See F2. | Code |
| Tenant-bypass row | The hash change c09304b6→7d79d25a is explained by BatchSize→batchSize; the reason is unchanged and accurate. Application tests are green in CI. |
CI Tests (application) pass |
Mutation reproduction
Each mutant was a single-match replacement in a script (1047-opus-work/run.sh), followed by a test run of the owning classes, then git checkout to revert. Every run used DOTNET_USE_POLLING_FILE_WATCHER=1. Baseline at the head: OperatorVerbContractTests, RefreshTokenPurgeBatchTests and ProvisionAccountCommandTests, 14/14 pass.
| # | Mutant | Result | Failing assertion |
|---|---|---|---|
| D9 (PR) | ProvisionedAccountDetails swaps slug and owner email |
red | ProvisionAccount_PrintsTheCommittedFarmCodeAccountAndOwner: Assert.Contains() Failure: Sub-string not found |
| D7 (PR) | recover forwards null for accountId |
red | RecoverAdmin_WithAccount_…: expected exit 0, got 1 |
| D15 (PR) | purge loses .Take(batchSize) |
red | one purge DELETE removed 525 rows; the batch bound is 500 |
| O1 (mine) | the details copy puts OwnerEmail into TemporaryPassword |
red | ProvisionAccountCommandTests.ProvisionAccount_UnderDmlOnlyRole_PrintsOneWorkingPasswordAfterCommit: Expected: 1 Actual: 2 |
| O2 (mine) | off-by-one bound, .Take(batchSize + 1) |
red | one purge DELETE removed 501 rows; the batch bound is 500 |
The PR's table matches what I reproduced. The batch test fails closed if EF's SQL text ever stops matching DELETE FROM refresh_tokens: the capture is empty, and attempt < 3 goes red on the third attempt.
security-review skill output
I invoked the skill. Its git preamble ran in the coordinator's worktree, not the review checkout, so its embedded diff was empty. I ran its methodology myself, without subagents, as the brief requires, over /home/mforce/.cluckwork-slices/reviews/1047-810fb67d.diff. It checks input validation, authN/authZ, crypto and secrets, injection, and data exposure, and applies its false-positive filter.
- No vulnerability reached confidence ≥ 8. The diff adds no HTTP surface. CLI arguments are trusted per the skill's precedent 3 and pass through unchanged to the services that validate them. The new SQL is the same parameterized EF
ExecuteDelete. No secret reaches a logger. No authorization path changes. - Candidates considered and dropped:
- The password in a synthesized record
ToString: pre-existing and never formatted. - The tenant-free purge: pre-existing, with a documented bypass row.
- The Redis closure (F1): no security impact, and an availability concern sits under the skill's DoS exclusion.
- The password in a synthesized record
thermo-nuclear-code-quality-review (P3, slice-scoped)
Full text: 1047-opus-work/thermo/00_summary.md. No structural regression. No file crosses 1k lines, and no new branch enters an existing flow.
- T1 (P3).
IAccessOperationsgroups operations by audience, not by dependency closure. One five-constructor facade makes every verb build the union of five closures, which is the root of F1. The fix is to split it into a lifecycle port (suspend, reactivate, rename) and a credential port (bootstrap, recover, provision). Both stay thin forwarders. - T2 (P3).
ProvisionedAccountDetailsplus its mapping exists only to avoid two testusinglines. MovingAccountProvisionOutcomelike the other four records deletes a type, a mapping and a class of swap bug. - T3 (P3). The moved record comments now sit in the Application contract but describe Infrastructure internals:
lockedAccount, and "not a mirror of AccountProvisionOutcome". Trim them to the contract-level fact on the next touch. - Nit.
using …Features.Users;sorts before…Features.Accountsin four services. Nothing enforces the order.
ponytail-review (P3, slice-scoped)
AccessOperationOutcomes.cs:L190-196andAccessOperations.cs:L269-278: reuse:ProvisionedAccountDetailsduplicatesAccountProvisioner.cs:204AccountProvisionOutcome. Move the original toApplication.Features.Usersand forward in one line, as the other five members do.IAccessOperationsandIRefreshTokenPurgeeach have one implementation but are not flagged as yagni. They are the module-boundary ports the Modular-monolith architecture design + implementation plan (from PR #423) #514 ledger and adapter-reach guard exist for, and the abstraction is the deliverable.
net: -12 lines possible.
Verdict
Merge. There is no product or security defect. F1 and F2 are optional P3 follow-ups; at minimum, the PR body should drop "nothing changes for operators" or note F1.
…r-verbs-and-purge # Conflicts: # tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md
|
Review of record (Codex Astra, xhigh) at head PR #1047 security review, #857 PR D. Reviewed No CONFIRMED or PLAUSIBLE P0–P3 findings. I found no product or security defect in this change. Operator parityEach operation reaches its original service with the original arguments. The CLI retains parsing, normalization, output and exit-code handling. Every cancellation token remains
The four moved result declarations and their comments are identical. The four affected service bodies are identical after removing their new import and relocated records. I independently ran the supplied 28-step process recorder on the head and tests-first commit Purge and lifetimes
The operations constructor does construct all five services, broadening the lifecycle verbs' dependency graph. I traced its configured Redis connection path rather than assuming construction performs no I/O. A scratch subprocess probe supplied an unavailable Sentinel endpoint, Mutation evidenceEach mutant was applied alone, rebuilt, run against one targeted class and restored. These are deliberately broken review copies, not findings against the PR.
The restored head passed 90/90 targeted integration tests across 12 classes and 12/12 Application architecture checks across five classes. The new committed tests also pass 3/3 before the move. Counts. Initial Application invocations returned 0 with empty logs before that project's restore; I discarded those results and reran with restore and explicit discovered-test counts. Verification notes. I did not reproduce the ordering or lifetime mutants. Ordering is independently checked by SQL comparison; scoped registrations and resolution were inspected directly. All Docker consumers used Ledger and code qualityThe ledger counts are exact: Access-to-Farm falls from 22 to 18 symbols when the four result records move, and Platform-to-Access grows from 45 to 46 adapters through the purge sweep. The remaining Access-to-Farm writes are unchanged. The regenerated matrix and tenant-bypass classification pass their real-tree checks. The thermo-nuclear review found no structural regression, added policy branch or unjustified file growth. The requested module boundary explains the direct forwarding layer; the small provision DTO copy preserves the agreed compatibility scope. Quality summary, detail. Ponytail-review: Lean already. Ship. NitsNone. All reviewer mutations and scratch tests were removed. The checkout was clean at the reviewed head before removal. No commit, push or GitHub comment was made, and no other checkout was edited. Cleanup record. Verdict: APPROVE PR #1047 at |
…r-verbs-and-purge # Conflicts: # src/Cluckwork.Api/Cli/SuspendAccountCliCommand.cs # tests/Cluckwork.Application.Tests/Architecture/Data/coupling-matrix.md # tests/Cluckwork.Application.Tests/Architecture/Data/module-ledger.json
Based on
main. D needs no code from B or C. Themainit is based on already contains B (#1037). Its overlap with #1041 (C) is in generated data only: both PRs change the Platform → Access count in the coupling matrix. Whichever merges second regenerates it.Nothing changes for operators. The six account verbs,
bootstrap-admin,recover-admin,provision-account,suspend-account,reactivate-accountandrename-account, keep every argument, message, exit code, audit row and generated password. The refresh-token purge deletes the same rows in the same batches. The trace below shows both.For maintainers, the verbs now reach Access through one surface,
IAccessOperations, instead of five Identity services. The purge's DELETE moves behindIRefreshTokenPurge, so the Platform job no longer queries an Access table directly.This is PR D of the Access contract (#857, design v2). It moves the break-glass and provisioning verbs, so it needs a security review.
Refs #857
What changed
da5672aa: tests first, green onmain. Three things the verbs and the purge do that no suite pinned exactly:provision-account's success line was checked withContains(email), which a result that swapped the farm code and the owner email still satisfies.OperatorVerbContractTestsrequires the exact line, with the account id read back from the database.recover-admin --account. The new test gives one email an Owner in two farms, runs--account <farm B> --reason, and requires that only farm B's user is reset and audited with that reason, and that farm A's password still works.RefreshTokenPurgeSweepTestschecks that every aged row goes, which an unbounded DELETE also satisfies.RefreshTokenPurgeBatchTestscounts the rows each purge DELETE removes and requires every statement to stay within the batch size. The host's own job worker also sweeps on its poll, so the test seeds and runs again, up to three times, until one of its statements sees a full batch.810fb67d: the move.IAccessOperations(Application.Features.Users):BootstrapAdminAsync,RecoverAdminAsync,ProvisionAccountAsync,SuspendAccountAsync,ReactivateAccountAsyncandRenameAccountAsync.AccessOperations(Infrastructure.Identity) forwards each to the service the verb called before. Every argument goes through unchanged, andCancellationToken.Noneis still the token.FirstRunAdminOutcome,AdminRecoveryResult,AccountLifecycleOutcomeandAccountRenameOutcomemove unchanged, comments included, intoApplication.Features.Users, so the contract can name them. No test names them.AccountProvisionOutcomestays, because existing suites name it. The verb receivesProvisionedAccountDetails, whichAccessOperationscopies field by field.IAccessOperations. Option parsing, slug normalisation,AccountSlugLookup, bootstrap'sMigrateAsync, every message and every exit code stay in the verbs, unchanged.AccountSlugLookupkeeps its signature, behaviour and place for [C] #514 slice 16: Platform composition, jobs, CLI and seeder conversion #858.IRefreshTokenPurge.DeleteExpiredBatchAsync(cutoff, batchSize, ct)holds the DELETE, moved verbatim with its comment.RefreshTokenPurgeSweepkeepsPurgeGrace,BatchSize,MaxBatchesPerRun, the loop, the logging and the error containment.RefreshTokenPurge.DeleteExpiredBatchAsync. Its token hash changes,c09304b6to7d79d25a, becauseTake(BatchSize)becameTake(batchSize), and its reason is unchanged after review. The matrix is regenerated.Parity trace
A scratch test (not committed) runs every verb as a real subprocess, in Production with only a connection string, against a fresh database. It runs 28 steps in one scripted sequence:
migrate;bootstrap-adminwithout an email, provisioning, and the idempotent re-run;provision-accountsucceeding with locale, currency and zone, plus a taken code, an unknown option, an invalid code and an unknown zone;recover-adminsucceeding, plus an unknown email, no email and a malformed--account;recover-adminrefusing it as ambiguous, then succeeding with--account;suspend-accountwithout a code, with an unknown code, succeeding with an upper-case code, and the no-op re-run;reactivate-accountsucceeding, and its no-op re-run;rename-accountsucceeding, the no-op, a taken code, an invalid code and an unknown farm;list-accounts.After each step it records the exit code, normalised stdout and stderr, and a database snapshot:
Version, zone, locale and currency;MustChangePassword,CredentialEpoch, disabled, lockout,StepUpLogoutEpoch, and whether the password hash or security stamp changed;Sequenceorder: action, entity, reason, actor, and its details keys;A second fact records the purge sweep's DELETE: its SQL text, its parameters and the rows it removed.
Production logs are compact JSON, so two values vary from run to run: each line's
@ttimestamp and EF'selapsedmilliseconds. With those two normalised, two runs onmainare identical, and the head is identical tomainacross all 28 steps and the purge statement. The purge SQL is the same text on both sides,DELETE … WHERE r."Id" IN (SELECT … WHERE r0."ExpiresAt" < @cutoff ORDER BY r0."ExpiresAt" LIMIT @p), with@p= 500.Mutation table
Each mutant was applied by an exact single-match replacement, built, and run against the classes that own its path, then reverted. Every run set
DOTNET_USE_POLLING_FILE_WATCHER=1. My first mutation run did not. Other agents' test runs on this host exhausted the inotify limit (128), and some CLI subprocesses died withIOExceptionor exit 134 instead of reaching their assertions. I discarded that run and reran everything. Every red below is an assertion failure, and the run logged no environment error.SuspendAccountAsynccallsReactivateAsyncAccountLifecycleCommandTests×6SuspendVerb_RunTwice…:Expected: 1Actual: 0ReactivateAccountAsynccallsSuspendAsyncAccountLifecycleCommandTests×3ReactivateVerb_OnAnAlreadyActiveFarm_ChangesNothing…:Expected: 1Actual: 0AccountLifecycleCommandTests×2Expected: "non-payment drill"Actual: nullAccountLifecycleCommandTests.ReactivateVerb_BringsTheFarmBack…Expected: "paid up"Actual: nullAccountLifecycleCommandTests.RenameVerb_ChangesTheCode…Expected: "rebrand drill"Actual: nullAccountLifecycleCommandTests×5expected exit 0, got 1. stderr=rename-account failed: Accounts.NotFound--accountOperatorVerbContractTests.RecoverAdmin_WithAccount…(new; survived without it)expected exit 0, got 1(the ambiguous-email refusal)Expected: "two-farm drill"Actual: nullProvisionedAccountDetailsswaps slug and owner emailOperatorVerbContractTests.ProvisionAccount_PrintsTheCommittedFarmCodeAccountAndOwner(new; survived without it)Assert.Contains() Failure: Sub-string not found"Farm provisioned: line-… (account …"ProvisionedAccountDetailsdrops the account idAssert.Contains() Failure: Sub-string not foundProvisionAccountCommandTests×4Expected: "Asia/Manila"Actual: "UTC"ProvisionAccountCommandTests.ProvisionAccount_UnderDmlOnlyRole…Expected: "es-MX"Actual: "en-US"Expected: "MXN"Actual: "USD"BootstrapAdminCommandTests×5expected exit 0, got 1. stderr=Bootstrap failed: Bootstrap.EmailRequiredTake(batchSize)RefreshTokenPurgeBatchTests(new; survived without it)Item: 525 Error: one purge DELETE removed 525 rows; the batch bound is 500RefreshTokenPurgeSweepTests.Sweep_DrainsABacklogLargerThanOneBatchevery aged row should drain, not just the first batchRefreshTokenPurgeSweepTests×4Sweep_LeavesActiveTokensUntouched…:Assert.True() Failuresuspend-accountverb callsReactivateAccountAsyncAccountLifecycleCommandTests×6Expected: 1Actual: 0Not run, with reasons:
OrderBychanges which aged rows go first only when a run hits its 20-batch cap. Every candidate is already past its ownExpiresAtplus the grace, so no order is unsafe. The parity trace pins theORDER BYtext instead.IAccessOperationsbehaves the same in a one-shot process, which resolves it once. A SingletonIRefreshTokenPurgewould hold oneAppDbContextacross the leader's sequential sweeps, and its untrackedExecuteDeletemakes that unobservable. Both stay Scoped, beside the services they forward to.Test counts
Measured on this machine. Every integration run has #1039's query-shape check on.
main75e2b1be810fb67dCluckwork.Domain.TestsCluckwork.Application.Tests(full, with every ledger, adapter-reach, tenant-bypass, seam and coupling-matrix guard)ProcessRoles,AccountSlugLookupor the new ports (58 classes, found by walking the test files)The one failure in that run was commit 1's batch test, run from a build made before its table-name fix. With the fix, commit 1's tests pass 3 of 3 on
mainand on the head, in a separate run.The full integration suite was not run locally; CI runs it.
Test edits
None. No existing test file changed.
git diff --numstat origin/main...HEAD -- tests/lists the two new test files and the three registry data files: the ledger, the coupling matrix and the tenant-bypass sites.Deslop record
/deslopand a by-hand/no-commentspass (subagents are off for this slice):srcdiff moves and forwards code only. It adds no logic, no caching and notry/catch. The one non-forwarding line is theProvisionedAccountDetailscopy, which the new exact-line test pins.IAccessOperations, the sweep/port split onIRefreshTokenPurge, and whyAccountProvisionOutcomestays."RefreshTokens"in the command text. The table isrefresh_tokens, so the test failed onmainwith an empty capture. It now matches the real table name and passes onmain.Change map
Review notes: properties to try to refute
IAccessOperationsmember calls the same service with the same arguments, in the same order, as the verb did. Account, reason, slug, locale, currency and zone are never dropped or swapped.ProvisionedAccountDetailscopiesAccountId,Slug,OwnerEmailandTemporaryPasswordexactly. The printed password is still the one that logs in, printed once, never through the logger.recover-adminresolves the same target, refuses the same ambiguous and disabled cases, and writes the sameUser.BreakGlassResetrow with the operator's reason. It still never migrates, so it still runs under the DML-only role.IAccessOperationsbuilds all five services, sosuspend-accountnow also constructsIdentityProvider's dependency closure.OneShotVerbMinimalConfigTestsrunsbootstrap-admin,provision-accountandrecover-adminwith only a connection string, and the broken-section cases runrecover-admin. Each of those reaches the service call, so the whole closure is constructed under minimal and broken configuration. The suspend, reactivate and rename cases still exit at the slug lookup before construction. Try to find a configuration under which a lifecycle verb now fails where it did not.PurgeGrace,BatchSize,MaxBatchesPerRun, the ordering and the strictly-older predicate are unchanged. Every statement removes at most one batch, and the sweep still contains its own failures. The port has no tenant scope, as before; refresh tokens carry no query filter, and the reason is on the tenant-bypass row.