Skip to content

Add typed settings persisted per site - #2766

Merged
pournasserian merged 7 commits into
mainfrom
feat/2704-typed-settings
Nov 4, 2026
Merged

pournasserian merged 7 commits into
mainfrom
feat/2704-typed-settings

Conversation

@pournasserian

Copy link
Copy Markdown
Member

Closes #2704

What it does

  • [Settings("area")] classes, registered with cms.Settings.Add<T>(); a bad registration stops the host (FCMS2801).
  • cms_setting (id, site_id, key, json, version, updated_at, updated_by), one AddSettings migration per provider. Instance defaults have a null site_id; two filtered unique indexes, and a site's rows go with the site.
  • ISettings<T> (SDK): GetAsync(site) resolves site override, then instance default, then code default, as whole documents. SetAsync/SetDefaultAsync/ResetAsync/ResetDefaultAsync write.
  • SettingsChangedEvent is raised after the commit (so no outbox-in-transaction API is needed; a write inside a transaction is refused with FCMS2807). It carries the area only; its source carries the site (empty for the instance defaults), so the existing activity entry is the site's row.
  • JSON schema per type (descriptor.CreateSchema()), from System.Text.Json plus Description, Range, StringLength, Min/MaxLength, RegularExpression, EmailAddress, Url and Required.
  • settings.{area}.manage in PermissionCatalogue (PermissionSource.Settings), which closes the item Permission catalogue, site-scoped roles and built-in roles #2700 left unticked.
  • Spec 4.7 has a "Settings as built" paragraph.

Security and correctness

  • Permission: SetAsync needs settings.{area}.manage for the site; the instance default needs a grant with no site limit and a role with no site (SettingsAccessDeniedException). Reads are not checked; the Management endpoint must check before showing a document.
  • Secrets: [SettingsSecret] or a name SecretScrubber.NamesSecret matches. Never in events, activity rows, logs, validation messages or schema defaults; the schema marks them writeOnly. They are stored in the row as-is; encryption at rest is not done.
  • Refused: invalid JSON, non-object, too large (FCMS2803), unknown key (FCMS2804, one error per key), wrong type, duplicate key or broken validation attribute (FCMS2805). Errors name the key, never the value. Reads are tolerant: a stored row the class can no longer read is skipped with a log line.
  • Concurrency: a write is one UPDATE (or an insert, retried if another writer inserted first), so documents never mix and the last write stands. A caller that names the version it read gets FCMS2808 if the row moved (internal SettingsService, for the Management endpoint's If-Match).
  • Cache: a per-instance cache keyed by scope and area, 30 s by default (cms.Settings.CacheDuration, zero turns it off). A write drops every scope of its area at once, and a read that raced with the write is not kept. Across instances there is no push: another instance sees a write when its own reads expire, so it lags by up to CacheDuration.
  • Unknown or deleted site: read and write give FCMS2806; deleting a site removes its settings by foreign key and drops what the cache held for it.

Acceptance

  • A plugin settings class is readable and writable per site: ASettingsClassIsReadableAndWritablePerSite, ASiteOverrideWinsOverTheInstanceDefaultWhichWinsOverTheCodeDefault, AReadGivesACopyThatTheCallerMayChange
  • Changing a setting raises an event and invalidates cached readers: ChangingASettingRaisesTheEventAndWritesAnActivityRowForTheSite, ChangingTheInstanceDefaultIsAnEventAndARowOfNoSiteEvenWhenTheOperationHasOne, AWriteDropsWhatReadersKeepAcrossScopes, AnotherInstanceSeesAWriteWhenItsReadsExpire, ACacheDurationOfZeroReadsTheDatabaseEveryTime
  • The JSON schema of a settings type is produced from its attributes: TheSchemaComesFromTheTypeAndItsAttributesAndShowsNoSecret
  • settings.{area}.manage in the catalogue: EveryAreaHasAPermissionInTheCatalogue
  • Permission checks: AWriteWithoutThePermissionChangesNothingAndTellsNoOne, ASiteGrantChangesThatSiteAndNeverTheInstanceDefaultOrAnotherSite, AGrantLimitedToSitesOfARoleWithNoSiteIsNotAnInstanceGrant, ThePermissionIsPerAreaAndThePrincipalMustHaveTheManagementScope, TheSystemPrincipalMayWriteAndNoOperationAtAllMayNot
  • Secrets: AWrittenSecretIsInTheTableAndInNoEventActivityRowOrLogLine, AValidationErrorOfASecretNeverRepeatsItsValue
  • Unknown keys and invalid JSON: ADocumentWithAnUnknownKeyOrInvalidJsonIsRefusedWithValidationErrors, AValueThatBreaksAValidationAttributeIsRefusedForTheTypedWriteToo, AnUnknownAreaIsRefused
  • Concurrent writes: ConcurrentWritesNeitherFailNorLoseARowAndEachRaisesItsEvent, AWriterThatNamesTheVersionItReadLosesToAnEarlierWriter, OnlyOneOfTwoFirstWritersThatNameVersionZeroWins
  • Deleted or unknown site: ASiteThatDoesNotExistIsRefusedOnReadAndWrite, DeletingASiteRemovesItsSettingsAndWhatReadersKeptOfThem
  • Also: AStoredDocumentTheClassCannotReadFallsBackAndAnOldKeyDoesNotBreakTheRead, ABadRegistrationStopsTheHost, ASettingsClassThatTheHostDidNotAddCannotBeResolved, AWriteInsideATransactionIsRefusedBeforeItChangesAnything, ResettingAnAreaThatHasNoRowRaisesNothing

Local checks

dotnet build -c Release -warnaserror, dotnet test, dotnet pack -c Release and markdown lint pass. FluentCmsTestHostTests.ABootSeedsTheTypesAndQueriesThemInUnderTwoSeconds has a 2 s budget and failed in one of three isolated runs on this machine (2.8 s), unrelated to settings. Existing tests that list system tables, count migrations or count model entity types were updated for cms_setting.

Dependencies

None. No new package; schema generation uses System.Text.Json's JsonSchemaExporter.

@pournasserian

Copy link
Copy Markdown
Member Author

Review fixes pushed: nested validation (paths like limits.port), secrets only at the top level with FCMS2801 below it, reset with version 0 conflicts, writes on a context of their own, omitted secret keys keep their value, a redacted resolve, and the site invalidation bumps the cache generation. Spec text updated. origin/main (#2767) merged; the full suite passes with the SQLite write interceptors.

@pournasserian

Copy link
Copy Markdown
Member Author

Second round pushed: a site's first write keeps the instance default's omitted secrets, RedactedSetting.Version is the version of the row a write would replace (0 when none), a stored secret the class can no longer accept goes back to its default with a warning naming the key, an error path under a secret is cut to the secret key, and a nested property named like a secret stops the host. Spec states that the row, its event and its activity row are not atomic. Full suite passes (2636).

@pournasserian

Copy link
Copy Markdown
Member Author

Third round pushed: the redacted version is that of the row a write replaces even when it cannot be read, a site's first write inherits only from an instance row the read could read, and secret names are matched by words (last word password, passphrase, secret or token; last two words api key, private key, secret key, access key, client secret or connection string) instead of substrings, for the top-level secret and the nested refusal alike. SecretScrubber is unchanged. Spec notes that an inherited secret is a copy. Build, tests (2670) and pack pass.

@pournasserian

Copy link
Copy Markdown
Member Author

Fourth round pushed: the name rule now ignores trailing digits and a version marker, accepts plurals, apikey, pwd, signing key and encryption key, and is applied to the C# member name as well as the JSON name; an unreadable site row no longer supplies omitted secrets (they come from the document the read resolved to). Spec says the name rule is a safety net and [SettingsSecret] is the real control. Build, tests (2697) and pack pass.

@pournasserian

Copy link
Copy Markdown
Member Author

Pushed: a write that skips the site's unreadable row now logs SecretDropped for each secret key that row held and the document did not name, by key only. Test added. Build, tests (2698) and pack pass.

@pournasserian
pournasserian merged commit 5be600c into main Nov 4, 2026
5 checks passed
@pournasserian
pournasserian deleted the feat/2704-typed-settings branch November 4, 2026 05:37
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.

Typed settings model persisted per site

1 participant