# Component: Security & Auth ## Purpose The Security & Auth component handles user authentication via LDAP/Active Directory and enforces role-based authorization across the system. It maps LDAP group memberships to system roles and applies permission checks to all operations. ## Location Central cluster. Sites do not have user-facing interfaces and do not perform independent authentication. ## Responsibilities - Authenticate users against LDAP/Active Directory using a direct LDAP/AD bind (username/password). - Map LDAP group memberships to system roles. - Enforce role-based access control on all API and UI operations. - Support site-scoped permissions for the Deployer role. ## Authentication - **Mechanism**: The Central UI presents a username/password login form. The app validates credentials by binding to the LDAP/AD server with the provided credentials, then queries the user's group memberships. - **Transport security**: LDAP connections **must** use LDAPS (port 636) or StartTLS to encrypt credentials in transit. Unencrypted LDAP (port 389) is not permitted. - **No local user store**: All identity and group information comes from AD. No credentials are cached locally. - **No Windows Integrated Authentication**: The app authenticates directly against LDAP/AD, not via Kerberos/NTLM. ## Dev Disable-Login Flag **`ScadaBridge:Security:Auth:DisableLogin`** (bool, default `false`) — when `true`, the Central UI bypasses the login form entirely and auto-authenticates every request as the user named by `ScadaBridge:Security:Auth:User` (default `multi-role`) with all six roles (Administrator, Designer, Deployer, Viewer, Operator, Verifier) granted system-wide. The mechanism is `AutoLoginAuthenticationHandler`, registered under the cookie scheme via `AddSecurity(disableLogin: true)`; because it sits in the cookie scheme, every existing authorization policy authenticates through it with zero policy changes required. **Environment guard (`DisableLoginGuard`).** Because the flag also grants Operator+Verifier — nullifying the two-person secured-write control — `DisableLogin=true` is **refused outside the Development environment**. `DisableLoginGuard.Validate` runs at the Host composition root (before `AddSecurity`) and throws at startup unless the environment is `Development` (case-insensitive) or the operator sets the explicit second acknowledgement key **`ScadaBridge:Security:Auth:AllowOutsideDevelopment`** (bool, default `false`). The ack key exists only for the rare deliberate case of an operator who fully accepts an unauthenticated deployment; absent it, a non-Development host fails fast rather than booting unauthenticated. > **Dev/test ONLY. Never enable in production.** Set in a local or docker-dev environment via the environment variable `ScadaBridge__Security__Auth__DisableLogin=true`. Note that `ScadaBridge:Security:Auth` is a child sub-section nested inside `ScadaBridge:Security`. ## Session Management ### Interactive sessions — cookie claims ticket - On successful interactive login (`POST /auth/login`) the app signs the user in with ASP.NET Core **cookie authentication**. The cookie carries a data-protected **claims ticket** (no embedded JWT): display name, username, roles (`Administrator`, `Designer`, `Deployer`, `Viewer`, `Operator`, `Verifier`), the permitted site IDs of a site-scoped Deployer, the user's raw LDAP groups (`zb:group`) and the idle / role-refresh anchors. The claim shape is built by one shared `SessionClaimBuilder`. - A cookie is the correct transport for Blazor Server: persistent SignalR circuits do not carry Authorization headers, and the browser sends the cookie with every SignalR connection and HTTP request. - The cookie is **HttpOnly**, `SameSite=Strict`, and **Secure** (requires HTTPS) unless `RequireHttpsCookie` is explicitly turned off for a plain-HTTP dev rig. - Both central nodes share the ASP.NET Data Protection key ring, so either node can read a cookie the other issued. ### CLI / API sessions — bearer JWT (`POST /auth/token`) - `POST /auth/token` (form `username`/`password`) performs the LDAP bind (login-throttled), maps the groups to roles and site scope, and returns a short-lived **HS256 JWT** signed with `SecurityOptions.JwtSigningKey`, shared by both central nodes: `{ access_token, token_type: "Bearer", expires_in, management_bearer: true, username, display_name, roles }`. - **JWT claims**: display name, username, roles, and for a site-scoped Deployer the permitted site IDs (no site claims = system-wide, the same normalisation the Basic path uses). Issuer and audience are both fixed to `scadabridge-central`. Lifetime is `JwtExpiryMinutes` (default **15**). - `ManagementAuthenticator` accepts `Authorization: Bearer ` alongside HTTP Basic on every CLI-facing surface: `POST /management`, the audit REST endpoints, and the debug-stream hub (`/hubs/debug-stream`, token in the `Authorization` header of the negotiate request and WebSocket upgrade). Validation checks the HS256 signature, issuer, audience and lifetime (30 s clock skew for node-to-node drift); unsigned (`alg=none`) or other-algorithm tokens are rejected, and a failure is a plain `401 AUTH_FAILED` (no LDAP bind, so it does not feed the login throttle). The token is never logged. - **Roles and scope inside a valid token are authoritative for its lifetime**: a bearer request performs no LDAP bind and no role re-map. A role revocation therefore reaches a bearer caller when its token expires (≤ `JwtExpiryMinutes`); rotating `JwtSigningKey` invalidates every outstanding token immediately. - The CLI exchanges credentials once per process, keeps the token in memory only, re-exchanges once on a `401`, and falls back to HTTP Basic when the server has no bearer support — see `Component-CLI.md`. HTTP Basic stays supported on every surface. - Under the dev/test `DisableLogin` flag `/auth/token` answers `404`: the management surfaces already authenticate every request as the dev user, and no credential-free token is minted that another deployment sharing the key might honour. - **Signing key**: `StartupValidator` refuses to boot a Central node whose `JwtSigningKey` is missing or shorter than **32 UTF-8 bytes** (256 bits, the HMAC-SHA256 floor). `JwtTokenService` repeats the length check at construction as defence in depth. ### Cookie session lifecycle - **Idle timeout**: Configurable, default **30 minutes**. Without eligible activity within the idle window, the session is rejected and the user must re-login. Passive `/auth/ping`, activity prevalidation, and Blazor transport requests preserve the activity anchor and suppress sliding renewal; the activity endpoint renews only after authentication and antiforgery validation. Tracked via a `LastActivity` last-activity timestamp claim. The cookie's `ExpireTimeSpan` is set to the idle timeout and `SlidingExpiration` renews it on activity, so the cookie window and the explicit `OnValidatePrincipal` idle check use the **same** value and cannot contradict each other. - **Role-mapping refresh (LDAP-free)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the stored groups — **with no LDAP call** — rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Ordinary HTTP validation uses this threshold. A live circuit refreshes mappings in fresh scopes on authentication-state reads — display reads (`GetAuthenticationStateAsync`) at most once per `CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval` (10 s) per circuit — and mutation entrypoints (`GetAuthoritativeStateAsync`) require a successful current mapping before every dispatch. - **Role-mapping cache**: `RoleMapper` reads the LDAP-group→role table and the scope rules of every Deployer mapping through a process-wide `RoleMappingCache` snapshot (`SecurityOptions.RoleMappingCacheSeconds`, default **30**; `0` disables). One refresh loads the whole table plus all Deployer scope rules, so a cache hit costs no database round-trip — previously every Basic-authenticated management request and every circuit auth-state read issued `GetAllMappingsAsync` plus one `GetScopeRulesForMappingAsync` per matched Deployer mapping. A mapping or scope-rule change is therefore visible on every node within the TTL. `RoleMappingCache.Invalidate()` makes it immediate on the node that served the change; wiring it into the ManagementActor role-mapping / scope-rule handlers is a pending follow-up, so today the TTL alone bounds the staleness (a refresh started before an invalidation is never published over it). #### Session Validation (`OnValidatePrincipal`) - The cookie principal is built at login by a **single shared claim-builder** (`SessionClaimBuilder`). The `OnValidatePrincipal` role-refresh path rebuilds the principal through the **same** builder, so the login and refresh claim shapes cannot drift. - **Failure policy**: ordinary cookie validation and display-state refresh retain existing grants on a mapping lookup error. Authoritative circuit mutation checks fail closed on the same error, so a mutation cannot proceed using grants that could not be refreshed. Idle timeout rejects the principal. > **Residual limitation — live LDAP group-membership changes (follow-up).** The > mid-session refresh re-maps the **stored** groups against the central database; it does > **not** re-query LDAP, so a change to the user's actual **group membership** in the > directory is picked up only at **next login**. A live group re-query for an active > session would require a new passwordless service-account group-search method on the > shared `ZB.MOM.WW.Auth.Ldap` library, which is an **external NuGet package** and exposes > only `AuthenticateAsync(username, password, ct)` (no standalone group search). Adding > that method is tracked as a follow-up. Until then: central role-mapping/scope changes are > reflected during HTTP refresh and immediately at circuit mutation checks; directory group-membership changes require re-login. ### Load Balancer Compatibility - Neither the cookie nor the bearer token needs server-side session state. A load balancer in front of the central cluster can route requests to either node without sticky sessions or a shared session store. - Both central nodes share the Data Protection key ring (cookie) and the `JwtSigningKey` (bearer token), so central failover is transparent to a valid cookie or token. ## LDAP Connection Failure - **New logins**: If the LDAP/AD server is unreachable, login attempts **fail**. Users cannot be authenticated without LDAP. - **Active sessions**: Users with a valid (not-idle-timed-out) session can **continue operating** with their current roles during an LDAP outage. Interactive cookie sessions never re-query LDAP mid-session (the mid-session role-mapping refresh is DB-only — see *Session Validation* above), so a brief LDAP outage does not disrupt engineers mid-work; central role-mapping changes still apply within the refresh window regardless of LDAP availability. A CLI holding a bearer token likewise keeps working until the token expires; HTTP Basic calls bind per request and fail during the outage. - **Recovery (group-membership changes)**: Because the mid-session refresh is LDAP-free, a change to a user's **directory group membership** is picked up at the user's **next login** (when LDAP is queried again), not mid-session — see the *Residual limitation* note above. ## Roles ### Administrator - **Scope**: System-wide (always). - **Permissions**: - Manage site definitions. - Manage site-level data connections (define and assign to sites). - Manage LDAP group-to-role mappings. - Manage API keys (create, enable/disable, delete). - System-level configuration. - View and export audit logs. ### Designer - **Scope**: System-wide (always). - **Permissions**: - Create, edit, delete templates (including attributes, alarms, scripts). - Manage shared scripts. - Manage external system definitions. - Manage database connection definitions. - Manage notification lists and SMTP configuration. - Manage inbound API method definitions. - Manage area definitions per site (any-of with Deployer — see note below). - Run on-demand validation (template flattening, script compilation). ### Deployer - **Scope**: System-wide or site-scoped. - **Permissions**: - Create and manage instances (overrides, connection bindings, area assignment). - Manage area definitions per site (any-of with Designer — see note below). - Disable, enable, and delete instances. - Deploy configurations to instances. - Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites. - View deployment diffs and status. - Use debug view. - Manage parked messages. - Monitor and manage the Notification Outbox (retry and discard parked notifications). - View site event logs. - **Site scoping**: A user with site-scoped Deployer role can only perform these actions for instances at their permitted sites. - **Administrator overrides site scoping.** A user who holds `Administrator` is system-wide **regardless of the `SiteId` claims they carry** — a scoped Deployer grant does not narrow an Administrator. This is enforced identically on both surfaces: `ManagementActor.EnforceSiteScope` (CLI / HTTP management API) and the Central UI's `SiteScopeService`. The two disagreed until deferred-work register row 86 — the UI was claims-only, so the same user saw the whole fleet through the CLI and only their sites in the UI. **No other role carries this bypass**; in particular `Designer` does not (Designer has no site-scoped permissions to bypass in the first place). - **The scope check fails CLOSED on an unresolvable target.** Site scoping works by resolving the command's target (instance id, site id/identifier, data-connection id) and comparing its site to the permitted set. When the target does not resolve, a **site-scoped caller is DENIED** — the same `Unauthorized` result an out-of-scope target produces. The rule is *a target that cannot be PROVEN to sit inside the caller's scope is treated as outside it*; it fails open only for callers who were never scoped (Administrator, or a Deployer with no site grants), who keep their ordinary "not found" answer. The check was fail-open until deferred-work register row 98, which also made the denial **indistinguishable from an out-of-scope denial** — same response shape, and a message that never says whether the target exists — so a scoped caller cannot use the gate to enumerate ids across the fleet. See `Component-ManagementService.md` → *Site-scope gate: fail-closed on an unresolvable target*. > **Area management (arch-review C6)**: Managing area definitions per site is gated **any-of [Designer, Deployer]**, not Administrator. Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies; Administrator is not implicitly included in the actor gate. ### Viewer - **Scope**: System-wide (always). - **Permissions**: - Read-only access to operational audit data (Audit Log, Notifications, Site Calls). - Navigation to audit pages. - Does not grant export or any mutating action. ### Operator - **Scope**: System-wide (always). - **Permissions**: - Submit a **secured write** to an MxAccess Gateway connection from the Central UI Secured Writes page (`/operations/secured-writes`) — the *initiating* half of the two-person write workflow. The **Tag path** is selected via the shared `NodeBrowserDialog` tag browser ("Browse…" button; the dialog's address-space search box is hidden for MxGateway connections, which have no search capability). The **Data type** is auto-filled best-effort from the selected tag's Galaxy data type via `SecuredWriteDataTypeMapper` and remains operator-editable. - **Purpose**: One of the two distinct global roles backing MxGateway secured writes. An Operator initiates a write; it never executes until a separate **Verifier** approves it. Holding Operator alone never executes a write. ### Verifier - **Scope**: System-wide (always). - **Permissions**: - Approve or reject a pending secured write from the Secured Writes page — the *approving* half of the two-person write workflow. - **Purpose**: The approving counterpart to **Operator**. Separation of duties is enforced **server-side**: the ManagementActor rejects any approval where the approving user equals the submitting Operator (no self-approval), so the two roles must be held by distinct principals for a write to execute. (See Component-ManagementService.md and Component-CentralUI.md.) > **Two-person secured-write workflow.** `Operator` and `Verifier` are deliberately separate global roles so a single principal cannot both initiate and approve a write through the MxAccess Gateway. Both are coarse global roles like the others. **Site scoping from the LDAP mapping is enforced server-side on every secured-write command** (arch-review R2 N3): submit checks the target site, approve/reject check the row's site (before the TTL/self-approval/CAS chain), and the list constrains a scoped caller to their permitted sites — mirroring the deployment commands' `EnforceSiteScope` rule. Administrators and system-wide principals (empty permitted-site set) are unrestricted. > > **Deployment-configuration hazard — never grant one principal both roles.** The whole control collapses if the same identity (or LDAP group) maps to *both* `Operator` and `Verifier`: server-side no-self-approval blocks approving the *exact* write you submitted, but a dual-role principal can still trivially pair-approve with a second submission, so a single compromised or careless account regains unilateral write. Keep `SCADA-Operators` and `SCADA-Verifiers` group membership disjoint. The dev `DisableLogin` caveat is the extreme of this: with `DisableLogin` on, the auto-login principal holds **all** roles, so the two-person flow cannot be exercised end-to-end by a single identity — which is exactly why `DisableLogin=true` is refused outside Development (see the `DisableLoginGuard` note above). No-self-approval is covered by handler tests; real two-person use requires two real, distinct-role identities. > > **Server-side pending TTL (arch-review S2).** A submitted secured write is not valid indefinitely. The ManagementActor enforces a configurable **`SecuredWritePendingTtl`** (`ManagementServiceOptions`, default **24 h**): a `Pending` row older than the TTL is atomically transitioned to **`Expired`** (compare-and-swap, so multi-node sweeps are idempotent) and can never be approved — the stale setpoint is never relayed to the site. Expiry is enforced eagerly at approve/reject (an overdue row fails with an "expired" error instead of executing) and opportunistically swept when the Secured Writes list is queried. A non-positive TTL disables expiry. This closes the "approve a days-old setpoint against a since-changed process" window. ## Authorization Policies Role checks are expressed as named ASP.NET Core authorization policies (in `AuthorizationPolicies`), each requiring the matching role claim: | Policy | Role required | Used by | |--------|---------------|---------| | `RequireAdmin` | Administrator | Site/data-connection management, API keys, LDAP mappings, bundle import, server-certificate trust/remove | | `RequireDesign` | Designer | Template authoring, shared scripts, external/DB definitions, notification lists, bundle export | | `RequireDeployment` | Deployer | Instance/deployment management, debug view, parked messages, Notification Outbox, Alarm Summary | | `RequireOperator` | Operator | Submit a secured write (Secured Writes page) | | `RequireVerifier` | Verifier | Approve/reject a pending secured write | `RequireOperator` and `RequireVerifier` were added with the two-person secured-write feature (M7); the rest predate it. ## Multi-Role Support - A user can hold **multiple roles simultaneously** by being a member of multiple LDAP groups. - Roles are **independent** — there is no implied hierarchy between roles. - For example, a user who is a member of both `SCADA-Designers` and `SCADA-Deploy-All` holds both the Designer and Deployer roles, allowing them to author templates and also deploy configurations. ## LDAP Group Mapping - System administrators configure mappings between LDAP groups and roles. - Examples: - `SCADA-Admins` → Administrator role - `SCADA-Designers` → Designer role - `SCADA-Deploy-All` → Deployer role (all sites) - `SCADA-Deploy-SiteA` → Deployer role (Site A only) - `SCADA-Deploy-SiteB` → Deployer role (Site B only) - `SCADA-Operators` → Operator role (initiates secured writes) - `SCADA-Verifiers` → Verifier role (approves secured writes) - A user can be a member of multiple groups, granting multiple independent roles. - Group mappings are stored in the configuration database and managed via the Central UI (Administrator role). - **Stored role casing is canonicalized at the write path** (arch-review C5): the mapping's `Role` arrives as a free string over the CLI/Management API, so `ManagementActor.HandleCreateRoleMapping`/`HandleUpdateRoleMapping` canonicalize it to the exact `Roles.All` casing (e.g. `deployer` → `Deployer`) before persisting, and reject any value not in `Roles.All`. This closes a CLI-works/UI-403s split-brain: the ASP.NET `RequireClaim` policies compare the role claim ordinal case-sensitively while the ManagementActor gates compare case-insensitively, so a row stored with off-canonical casing would pass every actor gate but fail every UI policy. Canonicalizing at this single server-side write path guarantees the two comparisons always agree. ## Permission Enforcement - Every API endpoint and UI action checks the authenticated user's roles before proceeding. - Site-scoped checks additionally verify the target site is within the user's permitted sites. - Unauthorized actions return an appropriate error and are not logged as audit events (only successful changes are audited). ## Dependencies - **Active Directory / LDAP**: Source of user identity and group memberships. - **Configuration Database (MS SQL)**: Stores LDAP group-to-role mappings and site scoping rules. - **Configuration Database (via IAuditService)**: Security/admin changes (role mapping updates) are audit logged. ## Interactions - **Central UI**: All UI requests pass through authentication and authorization. - **Template Engine**: Designer role enforcement. - **Deployment Manager**: Deployer role enforcement with site scoping. - **Management Service / Central UI (secured writes)**: `RequireOperator` gates submission and `RequireVerifier` gates approval/rejection of MxGateway secured writes; the no-self-approval rule (Operator ≠ Verifier) is enforced server-side by the ManagementActor, not just in the UI. - **All central components**: Role checks are a cross-cutting concern applied at the API layer. - **Management Service**: The ManagementActor enforces role-based authorization on every incoming command using the authenticated user identity carried in the message envelope. `ManagementAuthenticator` resolves that identity from either a bearer JWT (issued by `/auth/token` after the same LDAP bind + role mapping) or an HTTP Basic LDAP bind, and the identity (username, roles, permitted sites) travels in every request message. The ManagementActor applies the same role and site-scoping rules as the Central UI — no separate authentication path exists on the server side. - **Transport (#24)**: Provides the `RequireDesign` policy (export) and `RequireAdmin` policy (import) enforced at both the Razor page layer and inside the `ZB.MOM.WW.ScadaBridge.Transport` service entrypoints. ## Login throttling To blunt online password-spray and brute-force against the directory, every LDAP-bind surface consults a shared in-memory throttle (`LoginThrottle`, a `ZB.MOM.WW.ScadaBridge.Security` singleton) before attempting a bind, and records the bind outcome afterwards. - **Scope — every LDAP-bind surface**: the Central UI interactive login (`POST /auth/login`) and CLI token issue (`POST /auth/token`), plus the HTTP-Basic branch of the management/CLI surfaces fronted by `ManagementAuthenticator` — `POST /management`, the audit REST endpoints, **and the debug-stream hub (`DebugStreamHub.OnConnectedAsync` delegates its whole credential path to `ManagementAuthenticator.AuthenticateAsync`, closing the round-2 N1 gap where the hub bound LDAP directly and bypassed the throttle)**. No bind happens outside these paths; a bearer-token request binds nothing and is not throttled. The dev/test `DisableLogin` bypass sits **above** the throttle: a login-disabled deployment never binds, so it is never throttled. - **Key and window**: counters are keyed per `{username}|{IP}` pair (lower-cased username; the IP half is the **forwarded-header-resolved client IP** when `ScadaBridge:Security:ForwardedHeaders` names a trusted proxy, otherwise the raw connection peer). A fixed window of `LoginFailureWindowMinutes` (default 5) opens on the first failure; reaching `MaxLoginFailuresPerWindow` failures (default 5) within it locks that key out for `LoginLockoutMinutes` (default 5). A successful bind clears the key; an expired window resets the count. Setting `MaxLoginFailuresPerWindow` to `0` disables throttling entirely. - **Per-IP spray ceiling (arch-review 2026-08-19 P6-9).** The per-pair key alone is blind to a *spray* — one attacker IP trying one password against many distinct usernames never reaches `MaxLoginFailuresPerWindow` on any single pair — so a secondary counter keyed on the IP alone, same window/lockout but a higher ceiling (`PerIpMaxFailures`, default 25, validated strictly greater than `MaxLoginFailuresPerWindow`), locks out *new* attempts from that IP once it is reached; a successful bind does **not** reset the per-IP counter (only window/lockout expiry does), so a spray that stumbles onto one valid account cannot ride that success to keep spraying. - **Proxy topologies and the username-lockout DoS.** The key is deliberately `{username}|{IP}`, not `{username}` alone: keying on username alone would let any network-adjacent actor lock any operator out of a SCADA control surface with five wrong passwords, repeatable indefinitely. That isolation only exists when the throttle sees real client IPs — behind a proxy **without** ForwardedHeaders trust, all clients collapse onto the proxy's IP and the DoS returns. Mitigations: (a) the shipped Traefik topologies enable trusted-proxy ForwardedHeaders (so per-IP isolation is real in the documented deployment); (b) the lockout window is short by default (`LoginLockoutMinutes` = 5) and never touches the directory account itself; (c) residual risk — an attacker spraying from their *own* IP locking out a victim username *at that IP only* — is the throttle working as designed. A success-path bypass ("a correct password unlocks") was considered and rejected: it would let an attacker who has the password bypass the lockout entirely, and it converts the throttle into a password oracle during the lockout window. - **Behaviour when locked**: the surface refuses the bind without contacting LDAP. The Basic-Auth surfaces (`ManagementAuthenticator`) and `POST /auth/token` return HTTP `429` with `code = "AUTH_THROTTLED"`; `POST /auth/login` redirects to `/login` with a "Too many failed attempts. Try again later." message. - **Best-effort, per-node**: the throttle is in-memory with no external state. The two central nodes maintain independent counters (a spray hitting both simply burns its budget on each). Memory is bounded — writes prune expired keys and cap the tracked-key count. This is a rate-limit guard, not an account-lockout policy; it never disables the directory account itself. ### Passive traffic and circuit refresh (2026-09-09) `/auth/ping`, `/auth/activity`, and Blazor transport validation do not advance inactivity timestamps or trigger sliding renewal. The explicit activity endpoint validates antiforgery before renewing the cookie and recording activity by protected session ID. A bounded process-local tracker lets a live circuit observe successful activity renewal. Role refresh preserves the session ID and activity timestamp; current circuit mutation checks re-map stored groups with fail-closed lookup behavior. Display-only auth-state reads reuse the circuit's last revalidation for up to 10 s (review 2026-10-21 07/P-3), so an idle timeout or a revoked grant can show on a read-only surface for at most that long; it never reaches a mutation.