Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
542 changes: 541 additions & 1 deletion Core/Resgrid.AdminAssist/Catalog/security.yaml

Large diffs are not rendered by default.

71 changes: 63 additions & 8 deletions Core/Resgrid.Config/DataProtectionConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,24 +42,48 @@ public static class DataProtectionConfig
public static int BrokerTimeoutMs = 10000;

/// <summary>
/// Shared workload secret the application tier presents to the broker (X-Resgrid-Broker-Key).
/// Supplied through the environment/secret store only; an empty value on the broker refuses
/// every request (fail closed). This is defense-in-depth UNDER network isolation and mTLS —
/// never the only control.
/// LEGACY shared workload secret (X-Resgrid-Broker-Key with no client id). Superseded by per-host
/// credentials (<see cref="BrokerClientCredentials"/>, passkey plan section 8.5); the broker accepts it only
/// while <see cref="BrokerLegacySharedKeyEnabled"/> is on, with full authority, logging every use. A client
/// sends it only when it has no <see cref="BrokerClientId"/>. Supplied through the environment only.
/// </summary>
public static string BrokerApiKey = "";

/// <summary>
/// Broker side (passkey plan section 8.5): one credential per calling host role, as
/// <c>id=lanes|purposes|keyHash[,nextKeyHash];...</c>. Lanes are <c>attended</c>, <c>workload</c> and
/// <c>receipt</c>; purposes (workload lane only) must be in <see cref="BrokerWorkloadPurposes"/>; each key
/// hash is the lowercase or uppercase hex SHA-256 of the key's UTF-8 bytes, and a second hash allows rotation
/// without downtime. Example:
/// <c>api=attended,workload,receipt|records-export,invoicing|3f...;workers=workload|neris-submission|9a...;backoffice=receipt||c1...</c>.
/// An invalid map stops the broker at startup.
/// </summary>
public static string BrokerClientCredentials = "";

/// <summary>
/// Broker side: accept the legacy <see cref="BrokerApiKey"/> during the migration window (plan section 8.5
/// rule 6). Turn off, and remove the key, once the broker logs show no legacy use.
/// </summary>
public static bool BrokerLegacySharedKeyEnabled = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules high

BrokerLegacySharedKeyEnabled enables a full-authority legacy shared credential by default, violating deny-by-default and least-privilege principles during migration. Disable it by default and require an explicit, scoped authorization path.

Kody rule violation: Implement RBAC with least privilege and deny-by-default

public static bool BrokerLegacySharedKeyEnabled = false;
Prompt for LLM

File Core/Resgrid.Config/DataProtectionConfig.cs:

Line 67:

`BrokerLegacySharedKeyEnabled` enables a full-authority legacy shared credential by default, violating deny-by-default and least-privilege principles during migration. Disable it by default and require an explicit, scoped authorization path.

Suggested Code:

public static bool BrokerLegacySharedKeyEnabled = false;

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​


/// <summary>Client side: this host's broker credential id (X-Resgrid-Broker-Client), e.g. <c>api</c> or <c>workers</c>.</summary>
public static string BrokerClientId = "";

/// <summary>Client side: this host's broker credential key, supplied through the environment only.</summary>
public static string BrokerClientKey = "";

/// <summary>Maximum field items one broker request may carry; larger requests are refused.</summary>
public static int BrokerMaxItemsPerRequest = 200;

/// <summary>
/// Purposes the broker's workload decrypt lane (POST api/v1/broker/workload/decrypt?purpose=) accepts, comma
/// separated (RMS plan section 5.9.4). Each purpose is an egress the department acknowledged in the
/// application before the caller reaches the broker: neris-submission (worker 41), records-export
/// (worker 45 / Workflow renders) and invoicing (invoice delivery, pay links and deployment finance: the
/// Workforce &amp; Business Operations plan's document renders and the DTR void-reason append) and
/// application before the caller reaches the broker: neris-submission (worker 41), records-export (worker 45,
/// Workflow renders, and the Web and API "run now" export), invoicing (the customer contact on invoice and bid
/// delivery), workforce-costing and pay-data-reporting (Web workforce costing and CA pay-data runs), and
/// protected-workflow (an approved Protected Workflow release sending its allow-listed fields to its pinned
/// destination). Empty disables the lane; callers fail closed with workload_purpose_denied.
/// destination). Empty disables the lane; callers fail closed with workload_purpose_denied. Each host's
/// credential (<see cref="BrokerClientCredentials"/>) narrows this list further.
/// </summary>
public static string BrokerWorkloadPurposes = "neris-submission,records-export,invoicing,workforce-costing,pay-data-reporting,protected-workflow";

Expand Down Expand Up @@ -133,6 +157,37 @@ public static class DataProtectionConfig
/// <summary>Bounded clock skew allowed when validating grant lifetimes, in seconds.</summary>
public static int GrantClockSkewSeconds = 30;

/// <summary>
/// Filesystem path to the broker session-assertion SIGNING certificate (PFX with an ECDSA P-256 private key), on
/// Web and API hosts (passkey workbook section 6.2). A dedicated certificate: never the grant key and never an
/// OpenIddict key. Empty means no assertions are minted.
/// </summary>
public static string SessionAssertionSigningCertificatePath = "";

/// <summary>PFX password for the session-assertion signing certificate, supplied through the environment only.</summary>
public static string SessionAssertionSigningCertificatePassword = "";

/// <summary>
/// Filesystem path to the session-assertion VALIDATION certificate (public key only), on the broker. When empty,
/// validation falls back to the signing certificate's public part where that is configured (single-host development).
/// </summary>
public static string SessionAssertionValidationCertificatePath = "";

/// <summary>Issuer (iss) on broker session assertions; the audience is <see cref="BrokerAudience"/>.</summary>
public static string SessionAssertionIssuer = "resgrid-identity-session";

/// <summary>Session-assertion lifetime in seconds (the broker applies <see cref="GrantClockSkewSeconds"/> as skew).</summary>
public static int SessionAssertionLifetimeSeconds = 60;

/// <summary>
/// When true, the broker refuses attended decrypt and encrypt requests that carry no session assertion, even with a
/// version 1 grant. Version 2 grants always require one. Off until every Web and API host mints assertions.
/// </summary>
public static bool BrokerRequireSessionAssertion = false;

/// <summary>How long broker request ids and session-assertion ids stay recorded as used, in minutes.</summary>
public static int BrokerReplayWindowMinutes = 15;

/// <summary>
/// Key-wrapping provider the broker uses: "OpenBaoTransit" (production default), or "LocalDev"
/// for synthetic/non-PHI testing only — production startup must reject LocalDev.
Expand Down
98 changes: 98 additions & 0 deletions Core/Resgrid.Config/PasskeyConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
namespace Resgrid.Config
{
/// <summary>
/// Passkeys, Responder approval and provider step-up (passkey plan sections 10.2-10.3; Phase 0 workbook sections 5
/// and 12). Every rollout gate starts OFF. A gate that is ON still does nothing unless the relying-party registry
/// validates at startup, so a half-configured deployment fails closed. Turning a gate off stops new use only; it never
/// removes durable revocations or makes an old grant valid.
/// </summary>
public static class PasskeyConfig
{
// ── Rollout gates (all OFF) ───────────────────────────────────────────────────

/// <summary>Allow users to register passkeys.</summary>
public static bool RegistrationEnabled = false;

/// <summary>Accept passkeys as login MFA.</summary>
public static bool LoginAcceptanceEnabled = false;

/// <summary>Accept passkey evidence for Protected Data Grants.</summary>
public static bool AdpAcceptanceEnabled = false;

/// <summary>Issue version 2 Protected Data Grants. Every reader must support v2 before this is turned on.</summary>
public static bool EmitGrantV2 = false;

/// <summary>Allow shared-device (vehicle tablet / dispatch workstation) sessions.</summary>
public static bool SharedDeviceModeEnabled = false;

/// <summary>Allow "Approve with Responder" cross-app MFA.</summary>
public static bool ResponderApprovalEnabled = false;

/// <summary>Allow provider step-up (federated MFA) for departments that opt in.</summary>
public static bool ProviderStepUpEnabled = false;

// ── Relying parties (one per client; workbook section 5) ──────────────────────

/// <summary>
/// One relying party per client, separated by ";". Each entry is <c>client=rpId|origin,origin</c>, where client is
/// web, responder, unit, dispatch or command (ic), the RP ID is that client's own host, and each origin is
/// <c>https://host[:port]</c> under the RP ID or <c>android:apk-key-hash:&lt;base64url&gt;</c>. Example:
/// <c>web=app.resgrid.com|https://app.resgrid.com;unit=unit.resgrid.com|https://unit.resgrid.com,android:apk-key-hash:abc</c>.
/// Empty means passkeys are unavailable on this deployment.
/// </summary>
public static string RelyingParties = "";

/// <summary>Name the platform shows in the passkey prompt.</summary>
public static string RelyingPartyName = "Resgrid";

// ── Ceremony limits ───────────────────────────────────────────────────────────

public static int RegistrationChallengeLifetimeSeconds = 300;

public static int AssertionChallengeLifetimeSeconds = 120;

/// <summary>Failed verifications allowed against one challenge before it is spent.</summary>
public static int ChallengeMaxAttempts = 5;

/// <summary>Pending challenges one user may hold at once; more is refused rather than queued.</summary>
public static int MaxOutstandingChallengesPerUser = 10;

/// <summary>Active passkeys per user per client (at most 5 clients).</summary>
public static int MaxActiveCredentialsPerClient = 10;

public static int MaxDisplayNameLength = 100;

// ── Responder approval (plan section 7.9 abuse controls) ──────────────────────

/// <summary>An approval request lives this long and is never extended.</summary>
public static int ApprovalRequestLifetimeSeconds = 120;

/// <summary>Wrong numbers allowed before the request is denied.</summary>
public static int ApprovalMaxNumberAttempts = 3;

/// <summary>Approval requests one user may create per <see cref="ApprovalRateWindowMinutes"/>.</summary>
public static int ApprovalMaxRequestsPerWindow = 5;

public static int ApprovalRateWindowMinutes = 15;

/// <summary>After two denials or expiries in a row (or one "not me"), new requests are refused this long.</summary>
public static int ApprovalSuspensionMinutes = 15;

/// <summary>How long after its expiry an approved request can still be used by the requester's final poll.</summary>
public static int ApprovalConsumeGraceSeconds = 30;

// ── Shared vehicle and workstation sessions (plan sections 10.5 and 12.5) ─────

/// <summary>The longest idle lock a department may choose (at most 15 minutes).</summary>
public static int SharedMaxIdleLockMinutes = 15;

/// <summary>The longest shift a department may choose (at most 24 hours).</summary>
public static int SharedMaxShiftHours = 24;

/// <summary>Operator activity is written at most this often per session, so a busy screen is not a write per request.</summary>
public static int SharedActivityWriteIntervalSeconds = 30;

/// <summary>Unlock attempts one shared session may make per 5 minutes, on top of the account lockout.</summary>
public static int SharedUnlockMaxAttempts = 5;
}
}
5 changes: 5 additions & 0 deletions Core/Resgrid.Config/SessionSecurityConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,10 @@ public static class SessionSecurityConfig
public static int UserAgentMaximumLength = 1024;
// Optional local JSON CIDR database. Leave blank to display location as unavailable.
public static string IpLocationDatabasePath = "";

// How often each SignalR host rechecks the sessions behind its open connections and closes those that ended,
// locked or passed their idle deadline (passkey workbook section 12, slice 16). Invocations are checked on every
// call anyway; this bounds how long a passive connection keeps receiving broadcasts. 0 turns the sweep off.
public static int ConnectionSweepIntervalSeconds = 30;
}
}
54 changes: 54 additions & 0 deletions Core/Resgrid.Config/SsoConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,66 @@ public static class SsoConfig
/// </summary>
public static string SamlAcsPath = "/api/v4/connect/saml-mobile-callback";

/// <summary>
/// Relative URL path of the page that starts a legacy (unbrokered) SAML sign-in for an app: it sends the browser to the
/// department's IdP with an AuthnRequest. Discovery names it, with the department token, as <c>SamlLoginUrl</c>.
/// Example result: https://api.resgrid.com/api/v4/connect/saml-mobile-login
/// </summary>
public static string SamlLoginPath = "/api/v4/connect/saml-mobile-login";

/// <summary>
/// Relative URL path segment used to construct SAML SP Entity IDs.
/// Example result: https://api.resgrid.com/saml/{configId}
/// </summary>
public static string SamlEntityIdBasePath = "/saml/";

// ── Server-brokered SSO (passkey plan section 7.7.2; workbook section 7.3) ──

/// <summary>
/// Rollout gate for server-brokered SSO (<c>Sso/Begin</c>, the OIDC callback and brokered SAML, <c>Sso/Redeem</c>).
/// Off: those endpoints refuse, and the legacy client-run OIDC flow, SAML relay and <c>external-token</c> are unchanged.
/// </summary>
public static bool BrokeredSsoEnabled = false;

/// <summary>
/// Relative path of the OIDC redirect URI every department registers with its IdP for brokered SSO, appended to
/// <see cref="SystemBehaviorConfig.ResgridApiBaseUrl"/>. Example result: https://api.resgrid.com/api/v4/connect/oidc-callback
/// </summary>
public static string OidcCallbackPath = "/api/v4/connect/oidc-callback";

/// <summary>
/// The deployment's return-target registry: where the server may send the one-time <c>sso_code</c>, per client,
/// matched exactly. Entries are <c>client=target,target</c> separated by ";", where client is web, responder, unit,
/// dispatch or ic. A target is an https URL, an app's own custom scheme (never shared between apps), or an RFC 8252
/// loopback redirect written <c>http://127.0.0.1:*/path</c> (any port). Example:
/// <c>web=https://app.resgrid.com/Account/SsoReturn;unit=resgridunit://sso-return,https://unit.resgrid.com/sso-return,http://127.0.0.1:*/sso-return</c>.
/// Empty means brokered SSO has nowhere to return and is unavailable.
/// </summary>
public static string BrokeredReturnTargets = "";

/// <summary>
/// Where each app's web build is served, for the SSO pages' list of redirect URIs a department registers with its IdP
/// (a web build's legacy OIDC sign-in returns to its own page: <c>/auth/callback</c>, or <c>/login/sso</c> for Dispatch).
/// Entries are <c>client=origin</c> separated by ";", where client is responder, unit, dispatch or ic and origin is
/// <c>https://host[:port]</c> (http only for localhost or a .local host). An app with no entry has no web build here.
/// Defaults to the development hosts, like the other URL settings. The hosted US service's web editions are
/// <c>responder=https://responder.resgrid.com;unit=https://unit.resgrid.com;dispatch=https://dispatch.resgrid.com</c>;
/// a region lists its own hosts once it serves web editions. Only shown to admins: nothing is redirected by it.
/// </summary>
public static string AppWebOrigins = "responder=https://responder.resgrid.local;unit=https://unit.resgrid.local;dispatch=https://dispatch.resgrid.local";

/// <summary>How long a brokered SSO transaction waits for the IdP. Non-sliding.</summary>
public static int BrokeredTransactionLifetimeSeconds = 600;

/// <summary>How long the one-time <c>sso_code</c> can be redeemed.</summary>
public static int BrokeredCodeLifetimeSeconds = 60;

/// <summary>
/// For SSO reauthentication: the most time the IdP's own sign-in (<c>auth_time</c> or <c>AuthnInstant</c>) may
/// predate the callback. Sent as OIDC <c>max_age</c>; SAML sends <c>ForceAuthn</c>.
/// </summary>
public static int ReauthenticationMaxAgeSeconds = 300;

// ── Feature flags ─────────────────────────────────────────────────────

/// <summary>
Expand Down
Loading
Loading