Skip to content

feat: let an Owner turn connected apps off for the farm - #1147

Merged
mforce merged 6 commits into
mainfrom
feat/1146-connected-apps-switch
Oct 9, 2026
Merged

mforce merged 6 commits into
mainfrom
feat/1146-connected-apps-switch

Conversation

@mforce

@mforce mforce commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

An Owner can now turn connected apps off for the whole farm. The switch is Allow connected apps, at the top of Setup › Connected apps, the page that lists every app on the farm. It is not in Farm settings. It is on by default, so every existing farm keeps working.

Closes #1146

What off does

  • The consent screen says "This farm doesn't allow connected apps. Ask an Owner." It asks for no password and issues no code. Cancel still returns the user to the app with access_denied. The refusal takes focus and is announced in a polite live region.
  • Every OAuth token of the farm gets 401 Auth.ConnectedAppsOff on its next request. CredentialEpochVerifier reads the switch in the statement that already reads Account.IsActive, so the check adds no database round trip.
  • Registration is unaffected, because an app registers before anyone signs in.
  • Session sign-ins are unaffected. A token counts as an app's token when it carries client_id, which a session JWT never does (OAuth slice 6: audit provenance — record which application acted, alongside the human #800).

Turning it back on restores every connection nobody disconnected. Off refuses, and only Disconnect revokes. I found nothing that argues for revoking instead. OpenIddict's purge prunes only dead tokens and authorizations, so a farm that stays off for weeks loses nothing.

The control

The switch is a labelled MUI Switch with one line under it: "Off: new connections are refused, and connected apps stop on their next request." The page reads the switch and its version from GET /account with its rows, not from the session's farm, so a switch another Owner flipped shows correctly. A save announces "Connected apps are on." or "Connected apps are off." in a polite live region. A failed save restores the control and shows an error. After every save the page reloads, so the next flip uses the current version.

The endpoint

PUT /api/v1/account/connected-apps takes { allow, version }.

  • It is Owner-only (Farm configuration and identity become Owner-only, matching spec §5.1 #729) and needs an Idempotency-Key, like every write.
  • Account.SetConnectedApps bumps Version only on a real change, the way Rename does.
  • It shares the Version token with the Farm settings save. A racing settings save, or a second Owner holding an older version, gets 409 rather than a silent overwrite.
  • Each change writes Account.UpdateSettings with AllowConnectedApps before and after, and the Owner as actor. There is no new audit action; the maintainer accepted that.
  • An omitted allow is a 400, never a silent false.
  • AccountEndpoints.SetConnectedApps has its own RealModuleLedger.Adapters row, and the coupling matrix is regenerated.

The Farm settings PUT does not carry the field. An earlier version of this PR put the switch there, and the maintainer moved it to the Connected apps page.

The migration adds the column with a database default of true. The EF model sets no default, because a model default on a bool makes EF skip an inserted false.

The rationale is in docs/decisions/1146-connected-apps-switch.md, with a one-paragraph rule in src/AGENTS.md.

Precache

The precache measures 1,952.69 KiB. MUI Switch costs 5.60 KiB of that, and nothing else in the app uses it. The maintainer approved raising the ceiling from 1,950 to 1,960 KiB for this toggle, recorded in web/scripts/verify-sw.mjs and in the docs/designs/822-mui-revamp.md amendment. #1149, precaching only the active language, is the planned way to win the space back and lower the ceiling. Any further raise needs a new maintainer decision.

Accepted costs

  • If the switch goes off between the consent check and the code, that code still redeems. Its token is refused on its first request.
  • OAuthLastUsedStamp runs during token validation, before the refusal. So an app that retries while the switch is off still updates Last used. A suspended farm and a disabled user already behave this way.

Where to look

  • SetConnectedAppsHandler.cs, Account.SetConnectedApps and AccountEndpoints.SetConnectedApps hold the save.
  • CredentialEpochVerifier.cs and CredentialEpochMiddleware.cs hold the per-request check.
  • OAuthEndpoints.Authorize holds the consent refusal. Its adapter row now reaches Farm.
  • ConnectedAppsPage.tsx holds the control.
  • OAuthFailClosedTests.ConnectedAppsSwitch.cs has one test per rule. BaseReferenceDataMigrationTests compares AllowConnectedApps on the Base provisioning: static ref-data into schema + secure first-run admin (generated secret + MustChangePassword) — retire Seed:* config #283 base account row.
  • tools/oauth/mutation-check.sh gains 11 mutants and a MUTANT_FILTER variable to run a subset.

Verification

MUTANT_FILTER='^switch-' tools/oauth/mutation-check.sh killed all 11 mutants at 539fef75. Baseline and restore were both green at 125 of 125.

Mutant What it breaks Test that fails
switch-new-farm-off A new farm starts off ConnectedApps_AreOnByDefault_ForNewAndExistingFarms
switch-existing-farms-off The migration defaults existing farms off ConnectedApps_AreOnByDefault_ForNewAndExistingFarms
switch-not-owner-only PUT /account/connected-apps drops OwnerOnly ConnectedAppsSwitch_IsOwnerOnly
switch-token-unchecked The verifier ignores the switch ConnectedAppsOff_RefusesAnExistingToken_OnTheNextRequest
switch-token-unrecognised The middleware never flags an OAuth token ConnectedAppsOff_RefusesAnExistingToken_OnTheNextRequest
switch-refuses-sessions The middleware flags every token ConnectedAppsOff_LeavesSessionsAlone
switch-off-sticks Turning it back on does not restore ConnectedAppsBackOn_RestoresTheConnection
switch-consent-unchecked Consent skips the check ConnectedAppsOff_RefusesConsent_BeforeThePassword_AndIssuesNoCode
switch-registration-checked Registration consults the switch ConnectedAppsOff_LeavesRegistrationAlone
switch-unaudited The audit entry omits the switch's before value ConnectedAppsSwitch_IsAudited_WithTheOwnerAsActor
switch-race-unversioned SetConnectedApps skips Version++ ConnectedAppsSwitch_TwoOwnersAtOneVersion_ExactlyOneWins

The race between the switch and a Farm settings save at one version is ConnectedAppsSwitch_RacingASettingsSave_ExactlyOneWins. It passes, but it is not the race mutant's kill, because its outcome depends on which request lands first. Two Owners at one version is deterministic.

Measured locally at 539fef75. The later 4b828f21 changes only the page's failure path, and its specs pass 13 of 13 there:

  • The targeted integration run (OAuth, FarmSettings, CredentialEpoch, CurrencyLock, the sales settings callers, AdminGating, BaseReferenceData) passed 335 of 335.
  • With the migration's default set to false, BaseReferenceDataMigrationTests' virgin-database test fails with Expected: True, Actual: False.
  • The Application architecture, tenant-bypass and Accounts tests passed 513 of 513.
  • The affected Vitest files passed 785, plus 13 of 13 on the Connected apps specs. verify-sw.test.mjs passed 9 of 9.

The SPA mutants were applied by hand, run, and restored:

Mutant Spec that fails
The page ignores the fresh account read "shows the switch as the account reads it, not as this session saw it"
The save sends a stale version "turns apps off at the Version it read, and announces it"
The save announces nothing "turns apps off at the Version it read, and announces it"
A failed save skips the reload "restores the switch and says so when the save fails"
A failed save shows no error "restores the switch and says so when the save fails"
The consent refusal's heading is not focused both consent refusal specs, first load and after Allow
The consent refusal's live region stays empty both consent refusal specs, first load and after Allow

Screenshots are in the comment below, captured at the head named there.

mforce added 2 commits October 9, 2026 19:47
Allow connected apps is a Farm settings field, on by default. Off, consent
refuses before any password, and every OAuth token of the farm is refused on
its next request in the statement that already reads Account.IsActive.
Registration is unaffected. Back on restores every connection nobody
disconnected.

Closes #1146
@mforce

mforce commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner Author

Screenshots for #1146, captured at 1:1 at head 4b828f21. The stack was built with 941-mkstack.sh as the isolated project cw1146. The "before" frames come from the same recipe built from origin/main (a6729e48), which has not moved since. Light theme, 1280 and 390.

The switch now lives at the top of Setup › Connected apps. Farm settings is unchanged from main, so it has no frames here.

The sim stack serves plain http, and OpenIddict refuses OAuth requests over http. So both stacks ran behind a local Caddy https edge (https://localhost:9446), with the sim's trusted-proxy range widened to the Docker network for that run only. No rows were seeded. The listed app is a real approval made through the consent screen with the Owner's password, and every switch change went through the page's own control. The browser's only console errors were the service worker's script fetch (it rejects the edge's self-signed certificate), the sign-in page's usual session probe (401), and the expected 403 from the refused consent request.

Setup › Connected apps, before, and after with the switch on (1280).

before owner 1280 after switch on 1280

The same at 390.

before owner 390 after switch on 390

The switch turned off (1280 and 390). The control saved on its own, with no settings form involved.

switch off 1280 switch off 390

Consent, before and after with the switch off. Before shows the normal request. After shows the refusal, which asks for no password. Cancel returns the user to the app.

before consent 1280 after consent off 1280

before consent 390 after consent off 390

…e column

Round 1 of the #1147 review (Codex GPT-6 Astra):
- The Owner's Connected apps page reads AllowConnectedApps with its rows,
  so a switch flipped elsewhere shows in both directions.
- The consent refusal focuses its heading and is announced in a polite
  live region, on the first load and after Allow.
- The Farm settings section moves to ConnectedAppsSetting.tsx.

CI: the #283 base-reference guard now compares AllowConnectedApps. The
migration's default turns the base row on, and Account.Create starts on.
@mforce

mforce commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

Round 1 from Codex GPT-6 Astra is addressed in 891d5ae6.

F1 (P2), Owner page notice from the session's farm. Fixed. ConnectedAppsPage now fetches GET /account in the same Promise.all as the connections and users, and it no longer reads useFarm(). The notice is reloaded every time the page loads, and again after a Disconnect. There are two new specs, each with a stale session farm. The first seeds the session with apps on while the account read says off, and the notice appears. The second seeds the session with apps off while the account read says on, and the notice is absent. Two red mutations: dropping the fresh read fails the first spec, and also trusting the session's farm fails the second.

F2 (P2), consent refusal neither focused nor announced. Fixed. The off view's heading takes the existing heading ref and tabIndex={-1}, and the focus effect now runs for off as it does for ask. A role="status" region is present from the first render and receives the refusal text when the view turns off. That way both the first-load response and the Allow-time response are announced. Both consent refusal specs now assert focus on the heading and the announced text. Two red mutations: dropping off from the focus effect fails both specs, and so does leaving the live region empty.

F3 (P3), SettingsPage over 1,000 lines. The section moved to ConnectedAppsSetting.tsx with identical markup and behaviour. SettingsPage.tsx goes from 1,001 to 988 lines, and main has 982. The six lines left are the field's state, its load, its payload entry, the import and the placement. They stay because the save and its Version handling are shared by the whole page.

CI, BaseReferenceDataMigrationTests. The integration leg failed on 0031a146 because the #283 guard requires every mapped Account property to be classified. AllowConnectedApps is now in the compared set. The base account row is inserted before this migration, so the migration's database default turns it on, and Account.Create also starts on. The comparison is therefore meaningful. Against Docker the class passed 4 of 4. With the migration default set to false, the virgin-database test fails with Expected: True, Actual: False. The only reader of AssertExactMappedPropertyPartition is this test. AccountProvisioningTests uses the sibling helper only for grades and unit conversions.

The precache now measures 1,947.13 of 1,950 KiB. The screenshot comment is updated in place to this head.

@mforce

mforce commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

I checked the round-1 fixes (0031a14..891d5ae) directly, without another review round:

  • The Owner page reads the switch from GET /account in the same batch as the rows.
  • The consent "off" view focuses its heading and announces through a status region that exists from the first render.
  • The settings section moved into ConnectedAppsSetting.tsx.
  • AllowConnectedApps is classified in the base-reference comparison, and the virgin-database test fails if the migration default is false.

CI is green at 891d5ae, and the review loop for this PR is complete. Toggling the switch stays audited as the existing farm-settings update, with the before and after flag and the Owner as actor; there is no separate audit action.

mforce added 3 commits October 9, 2026 21:11
The maintainer moved the switch out of Farm settings. The Owner now flips
it at the top of Setup > Connected apps, a labelled MUI Switch, saved through
its own Owner-only PUT /account/connected-apps under the settings Version
token. Account.SetConnectedApps bumps Version only on a real change, and
each change writes Account.UpdateSettings with the switch before and after.
The Farm settings PUT no longer carries the field.

The maintainer approved raising the precache ceiling to 1,960 KiB for the
toggle; #1149 is the planned way to lower it again.
The explicit restore duplicated the reload that follows every save, which
already brings the control back to what the server holds.
@mforce

mforce commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

The switch has moved, as the maintainer asked. It now sits at the top of Setup › Connected apps, not in Farm settings. The commits are 49f7e804, 539fef75 and 4b828f21.

Farm settings. ConnectedAppsSetting.tsx is deleted. SettingsPage.tsx, its test and the Farm settings PUT contract are back to main's shape. Following #394, the field's callers were the SPA, the simulation seeder and the tests, and each is reverted. The k6 and Playwright specs never sent it.

Connected apps page. A labelled MUI Switch replaces the "apps are off" warning, with the line "Off: new connections are refused, and connected apps stop on their next request." It reads the value and its version from GET /account with the page's rows. A save announces the new state in a polite live region. A failed save shows an error, and the reload that follows every save puts the control back to what the server holds.

Endpoint. PUT /api/v1/account/connected-apps takes { allow, version }.

  • It is Owner-only and needs an Idempotency-Key.
  • Account.SetConnectedApps bumps Version only on a real change.
  • It shares the Version token with Farm settings, so a racing settings save or a second Owner gets 409.
  • Each change writes Account.UpdateSettings with the switch before and after and the Owner as actor. There is no new audit action.
  • AccountEndpoints.SetConnectedApps has its own adapter row, and the coupling matrix is regenerated.

Tests. The Owner-only, version race, audit, consent refusal, token refusal, restore and registration tests now drive the new endpoint. A new test races two Owners holding the same version, which kills the Version mutant whichever request lands first. The test that races a Farm settings save against the switch still passes. All 11 switch mutants were killed through tools/oauth/mutation-check.sh (baseline and restore 125 of 125). Five page mutants and the two consent-refusal mutants go red. The PR body lists each mutant and the test that kills it.

Precache. The page measures 1,952.69 KiB, of which MUI Switch is 5.60 KiB. The maintainer approved raising the ceiling to 1,960 KiB. verify-sw.mjs and the 822-mui-revamp.md amendment record the raise, name #1149 as the planned way to lower it again, and say any further raise needs a new maintainer decision.

Docs. Help, the in-app glossary and GLOSSARY.md name the switch by its label in en, es and tl and say where it lives (#688). The settings strings and the old warning are removed. The decision record and the src/AGENTS.md rule describe the new endpoint and page.

The screenshot comment is updated in place to 4b828f21.

@mforce

mforce commented Oct 9, 2026

Copy link
Copy Markdown
Owner Author

I checked the move to Setup › Connected apps (891d5ae..4b828f2) directly:

  • PUT /api/v1/account/connected-apps is Owner-only.
  • SetConnectedAppsHandler compares the sent Version with the tracked account. The mutation bumps Version only on a real change, and EF's concurrency token catches a race between read and save.
  • The audit entry stays Account.UpdateSettings with the before and after flag.
  • Farm settings no longer carries the field.
  • The precache ceiling is 1,960 KiB, a raise the maintainer approved for the sliding toggle. Precache only the active language in the SPA service worker #1149 is the planned way to win that space back.

CI failed four times on Docker Hub's anonymous pull rate limit, not on the code. Attempt 4 passed. The review loop for this PR is complete.

@mforce
mforce merged commit 077f27e into main Oct 9, 2026
43 of 64 checks passed
@mforce
mforce deleted the feat/1146-connected-apps-switch branch October 9, 2026 22:57
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.

OAuth slice 7: let an Owner turn connected apps on or off for the farm

1 participant